Compare commits

..
Author SHA1 Message Date
Kit Langton de77a89039 fix(sdk): resolve packed artifacts under strict Node ESM 2026-08-24 10:40:33 -04:00
188 changed files with 2745 additions and 11785 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2bkzaLe/n63btVRQNhu8LXCtMZJArX1Kedi5U40l1xw=",
"aarch64-linux": "sha256-5Cs9M3hvDKAymo71y8oZ7jj3pEm+MI+HHhuSuV7UvtM=",
"aarch64-darwin": "sha256-LsJcuxE/NMu+vUFdpBKHc2z0sC0C5bRMlH1Kj+ns9dY=",
"x86_64-darwin": "sha256-KDjmKC3JZD8I5A7gi+dYIl0dgHVt20/DwkM9RKBWiJk="
"x86_64-linux": "sha256-phyTF0/jQZ3L0B66PSLdpH//kyPc1M6j5a40wCSx7TA=",
"aarch64-linux": "sha256-1Zb/Is0ujIslCbPPusAVhcuzAPyIauQyeIIRRGtzpAk=",
"aarch64-darwin": "sha256-DDsVm7z+PSDry6QqrwVDFSmEnq6jIKb709Y4ymAv9f8=",
"x86_64-darwin": "sha256-S+5LI2J+WRhRP7jp2PAv6AesXk238wEYoyIO1oKdF3w="
}
}
@@ -157,7 +157,7 @@ type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
const AnthropicThinkingBlock = Schema.Struct({
type: Schema.tag("thinking"),
thinking: Schema.String,
signature: Schema.String,
signature: Schema.optional(Schema.String),
cache_control: Schema.optional(AnthropicCacheControl),
})
@@ -701,26 +701,6 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
const requireThinkingSignature = (request: LLMRequest) => {
if (request.model.compatibility?.requireSignature !== undefined)
return request.model.compatibility.requireSignature
const provider = request.model.provider.toLowerCase()
const model = request.model.id.toLowerCase()
const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase()
if (
provider === "kimi-for-coding" ||
provider === "moonshotai" ||
provider === "moonshotai-cn" ||
model.startsWith("kimi-") ||
baseURL.includes("api.kimi.com/coding") ||
baseURL.includes("api.moonshot.ai/anthropic") ||
baseURL.includes("api.moonshot.cn/anthropic")
)
return false
if (provider.includes("xiaomi") || model.includes("mimo") || baseURL.includes("xiaomimimo.com")) return false
return true
}
// Mid-conversation system messages became available with Opus 4.8 and version
// 5 of the other supported Claude families. Treat later family versions as
// compatible without assuming that every Anthropic Messages model is Claude.
@@ -827,30 +807,15 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
// A signature marks visible thinking; only signature-less parts carrying
// redactedData round-trip as opaque redacted_thinking blocks.
// Mirrors Vercel's @ai-sdk/anthropic: 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)
const redactedData = redactedDataFromMetadata(part.providerMetadata)
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
}
if (typeof signature !== "string" || signature.trim().length === 0) {
if (part.text.trim().length === 0) continue
if (!requireThinkingSignature(request)) {
content.push({ type: "thinking", thinking: part.text, signature: "" })
continue
}
// Without a signature this cannot be a valid thinking block per
// the SDK ThinkingBlockParam:3217 — demote to text so the
// conversation remains sendable.
content.push({
type: "text",
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
})
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
continue
}
+4 -29
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -125,7 +125,6 @@ const GeminiContentPart = Schema.Union([
GeminiFunctionCallPart,
GeminiFunctionResponsePart,
])
const decodeGeminiContentPart = Schema.decodeUnknownOption(GeminiContentPart)
const GeminiContent = Schema.Struct({
role: optionalNull(Schema.Literals(["user", "model"])),
@@ -133,11 +132,6 @@ const GeminiContent = Schema.Struct({
})
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
const GeminiResponseContent = Schema.Struct({
role: optionalNull(Schema.Literals(["user", "model"])),
parts: optionalNull(Schema.Array(Schema.Unknown)),
})
const GeminiSystemInstruction = Schema.Struct({
parts: Schema.Array(Schema.Struct({ text: Schema.String })),
})
@@ -206,7 +200,7 @@ const GeminiUsage = Schema.Struct({
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
const GeminiCandidate = Schema.Struct({
content: optionalNull(GeminiResponseContent),
content: optionalNull(GeminiContent),
finishReason: optionalNull(Schema.String),
})
@@ -228,7 +222,6 @@ const GeminiEvent = Schema.Struct({
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly route: string
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly promptFeedback?: GeminiPromptFeedback
@@ -605,21 +598,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
const seenCallIds = new Set(nextState.seenCallIds)
for (const input of candidate.content.parts ?? []) {
if (
ProviderShared.isRecord(input) &&
!("text" in input) &&
!("inlineData" in input) &&
!("functionCall" in input) &&
!("functionResponse" in input)
)
continue
const decoded = decodeGeminiContentPart(input)
if (Option.isNone(decoded))
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const part = decoded.value
for (const part of candidate.content.parts ?? []) {
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
@@ -712,11 +691,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: (request) => ({
route: `${request.model.provider}/${request.model.route.id}`,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
}),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
step,
onHalt: finish,
},
+2 -9
View File
@@ -666,7 +666,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
@@ -684,18 +683,11 @@ const lowerOptions = (request: LLMRequest) => {
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(parallelToolCalls !== undefined ? { parallel_tool_calls: parallelToolCalls } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
export const resolveParallelToolCalls = (request: LLMRequest) => {
const configured = OpenResponsesOptions.resolve(request).parallelToolCalls
if (configured !== undefined) return configured
const disabled = request.toolChoice?.disableParallelToolUse
return disabled === undefined ? undefined : !disabled
}
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
@@ -1194,6 +1186,7 @@ export const step = (state: ParserState, event: Event) => {
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_summary.done" ||
event.type === "response.reasoning_text.done"
) {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
+7 -143
View File
@@ -51,12 +51,7 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
}),
function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -138,7 +133,6 @@ export const bodyFields = {
store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
tool_stream: Schema.optional(Schema.Boolean),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -270,18 +264,12 @@ interface LoweringOptions {
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
}
const lowerTool = (
tool: ToolDefinition,
inputSchema: JsonSchema,
options: LoweringOptions,
supportsStrictMode: boolean,
): OpenAIChatTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: inputSchema,
...(supportsStrictMode ? { strict: false } : {}),
},
cache_control: options.cacheControl?.(tool.cache),
})
@@ -540,122 +528,11 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
return false
}
// Derive `max_tokens` vs `max_completion_tokens` from provider/baseURL when
// explicit `compatibility.maxTokensField` is not set. Aligned with
// models.dev provider naming: DeepSeek, Moonshot AI, Together AI, ZAI
// (Zhipu + Coding Plan variants), Nvidia, Cerebras, Chutes, etc. still
// require `max_tokens`.
const detectMaxTokensField = (provider: string, baseURL: string | undefined): "max_tokens" | "max_completion_tokens" => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
if (
p === "deepseek" ||
url.includes("deepseek.com") ||
p === "moonshotai" ||
url.includes("api.moonshot.ai") ||
p === "togetherai" ||
url.includes("api.together.") ||
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn") ||
p === "nvidia" ||
url.includes("integrate.api.nvidia.com") ||
p === "cerebras" ||
url.includes("cerebras.ai") ||
url.includes("llm.chutes.ai") ||
p === "chutes" ||
p === "cloudflare-ai-gateway" ||
url.includes("gateway.ai.cloudflare.com") ||
p === "cloudflare-workers-ai" ||
url.includes("api.cloudflare.com")
)
return "max_tokens"
return "max_completion_tokens"
}
const detectSupportsStore = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
const isDeepSeek = p === "deepseek" || url.includes("deepseek.com")
const isCerebras = p === "cerebras" || url.includes("cerebras.ai")
const isXai = p === "xai" || url.includes("api.x.ai")
const isChutes = p === "chutes" || url.includes("chutes.ai")
const isCloudflareWorkersAI = p === "cloudflare-workers-ai" || url.includes("api.cloudflare.com")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isVercelAiGateway = p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
const isOpencode = p === "opencode" || url.includes("opencode.ai")
const isNonStandard =
isNvidia ||
isCerebras ||
isXai ||
isTogether ||
isChutes ||
isDeepSeek ||
isZai ||
isMoonshot ||
isOpencode ||
isCloudflareWorkersAI ||
isCloudflareAiGateway ||
isVercelAiGateway ||
isAntLing
return !isNonStandard
}
const detectSupportsUsageInStreaming = (): boolean => true
const detectSupportsStrictMode = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
}
const detectZaiToolStream = (
provider: string,
baseURL: string | undefined,
modelID: string,
): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
if (!isZai) return false
const id = modelID.toLowerCase()
if (id === "glm-4.5" || id === "glm-4.5-air" || id === "glm-4.5-flash" || id === "glm-4.5v") return false
return true
}
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
// even when no explicit `providerOptions.store` was supplied, mirroring the
// native OpenAI Chat default. Non-standard providers omit `store` entirely.
...(supportsStore && options.store === undefined ? { store: false } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
@@ -674,19 +551,8 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const provider = String(request.model.provider)
const baseURL = request.model.route.endpoint.baseURL
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
const maxTokensField = request.model.compatibility?.maxTokensField ?? detectedMaxTokensField
const supportsStore = request.model.compatibility?.supportsStore ?? detectSupportsStore(provider, baseURL)
const supportsUsageInStreaming =
request.model.compatibility?.supportsUsageInStreaming ?? detectSupportsUsageInStreaming()
const supportsStrictMode = request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
const zaiToolStream =
request.model.compatibility?.zaiToolStream ??
detectZaiToolStream(provider, baseURL, request.model.id)
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = request.tools.length > 0
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
@@ -700,13 +566,11 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
supportsStrictMode,
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}),
stream_options: { include_usage: true },
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
@@ -716,7 +580,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
presence_penalty: generation?.presencePenalty,
seed: generation?.seed,
stop: generation?.stop,
...lowerOptions(request, supportsStore),
...lowerOptions(request),
}
})
@@ -111,10 +111,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return {
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
tools:
request.tools.length === 0
? undefined
@@ -164,7 +162,7 @@ const HOSTED_TOOLS = {
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
@@ -1,64 +0,0 @@
/*
* Adapted from partial-json by the Promplate Dev Team:
* https://github.com/promplate/partial-json-parser-js/blob/main/src/options.ts
* Licensed under the MIT License; see partial-json.ts for the complete notice.
*/
/**
* allow partial strings like `"hello \u12` to be parsed as `"hello `
*/
export const STR = 0b000000001
/**
* allow partial numbers like `123.` to be parsed as `123`
*/
export const NUM = 0b000000010
/**
* allow partial arrays like `[1, 2,` to be parsed as `[1, 2]`
*/
export const ARR = 0b000000100
/**
* allow partial objects like `{"a": 1, "b":` to be parsed as `{"a": 1}`
*/
export const OBJ = 0b000001000
/**
* allow `nu` to be parsed as `null`
*/
export const NULL = 0b000010000
/**
* allow `tr` to be parsed as `true`, and `fa` to be parsed as `false`
*/
export const BOOL = 0b000100000
/**
* allow `Na` to be parsed as `NaN`
*/
export const NAN = 0b001000000
/**
* allow `Inf` to be parsed as `Infinity`
*/
export const INFINITY = 0b010000000
/**
* allow `-Inf` to be parsed as `-Infinity`
*/
export const _INFINITY = 0b100000000
export const INF = INFINITY | _INFINITY
export const SPECIAL = NULL | BOOL | INF | NAN
export const ATOM = STR | NUM | SPECIAL
export const COLLECTION = ARR | OBJ
export const ALL = ATOM | COLLECTION
/**
* Control what types you allow to be partially parsed.
* The default is to allow all types to be partially parsed, which in most cases is the best option.
*/
export const Allow = { STR, NUM, ARR, OBJ, NULL, BOOL, NAN, INFINITY, _INFINITY, INF, SPECIAL, ATOM, COLLECTION, ALL }
export default Allow
@@ -1,223 +0,0 @@
/*
* Adapted from partial-json by the Promplate Dev Team:
* https://github.com/promplate/partial-json-parser-js
*
* MIT License
*
* Copyright (c) 2023 Promplate Dev Team
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Schema } from "effect"
import { Allow } from "./partial-json-options.js"
export * from "./partial-json-options.js"
export class PartialJSON extends Error {}
export class MalformedJSON extends Error {}
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))
/** Parse complete or incomplete JSON, restricted by the supplied partial-value flags. */
export function parseJSON(jsonString: string, allowPartial = Allow.ALL): unknown {
if (typeof jsonString !== "string") throw new TypeError(`expecting str, got ${typeof jsonString}`)
const input = jsonString.trim()
if (!input) throw new Error(`${jsonString} is empty`)
try {
return decodeJson(input)
} catch {}
return _parseJSON(input, allowPartial)
}
const _parseJSON = (jsonString: string, allow: number) => {
const length = jsonString.length
let index = 0
const markPartialJSON = (message: string): never => {
throw new PartialJSON(`${message} at position ${index}`)
}
const throwMalformedError = (message: string): never => {
throw new MalformedJSON(`${message} at position ${index}`)
}
const parseAny = (): unknown => {
skipBlank()
if (index >= length) markPartialJSON("Unexpected end of input")
if (jsonString[index] === '"') return parseStr()
if (jsonString[index] === "{") return parseObj()
if (jsonString[index] === "[") return parseArr()
if (
jsonString.substring(index, index + 4) === "null" ||
(Allow.NULL & allow && length - index < 4 && "null".startsWith(jsonString.substring(index)))
) {
index += 4
return null
}
if (
jsonString.substring(index, index + 4) === "true" ||
(Allow.BOOL & allow && length - index < 4 && "true".startsWith(jsonString.substring(index)))
) {
index += 4
return true
}
if (
jsonString.substring(index, index + 5) === "false" ||
(Allow.BOOL & allow && length - index < 5 && "false".startsWith(jsonString.substring(index)))
) {
index += 5
return false
}
if (
jsonString.substring(index, index + 8) === "Infinity" ||
(Allow.INFINITY & allow && length - index < 8 && "Infinity".startsWith(jsonString.substring(index)))
) {
index += 8
return Infinity
}
if (
jsonString.substring(index, index + 9) === "-Infinity" ||
(Allow._INFINITY & allow &&
1 < length - index &&
length - index < 9 &&
"-Infinity".startsWith(jsonString.substring(index)))
) {
index += 9
return -Infinity
}
if (
jsonString.substring(index, index + 3) === "NaN" ||
(Allow.NAN & allow && length - index < 3 && "NaN".startsWith(jsonString.substring(index)))
) {
index += 3
return NaN
}
return parseNum()
}
const parseStr = (): string => {
const start = index
let escape = false
index++
while (index < length && (jsonString[index] !== '"' || (escape && jsonString[index - 1] === "\\"))) {
escape = jsonString[index] === "\\" ? !escape : false
index++
}
if (jsonString.charAt(index) === '"') {
try {
return decodeJson(jsonString.substring(start, ++index - Number(escape))) as string
} catch (error) {
throwMalformedError(String(error))
}
}
if (Allow.STR & allow) {
try {
return decodeJson(`${jsonString.substring(start, index - Number(escape))}"`) as string
} catch {
return decodeJson(`${jsonString.substring(start, jsonString.lastIndexOf("\\"))}"`) as string
}
}
return markPartialJSON("Unterminated string literal")
}
const parseObj = (): Record<string, unknown> => {
index++
skipBlank()
const object: Record<string, unknown> = {}
try {
while (jsonString[index] !== "}") {
skipBlank()
if (index >= length && Allow.OBJ & allow) return object
const key = parseStr()
skipBlank()
index++
try {
object[key] = parseAny()
} catch (error) {
if (Allow.OBJ & allow) return object
throw error
}
skipBlank()
if (jsonString[index] === ",") index++
}
} catch {
if (Allow.OBJ & allow) return object
return markPartialJSON("Expected '}' at end of object")
}
index++
return object
}
const parseArr = (): unknown[] => {
index++
const array: unknown[] = []
try {
while (jsonString[index] !== "]") {
array.push(parseAny())
skipBlank()
if (jsonString[index] === ",") index++
}
} catch {
if (Allow.ARR & allow) return array
return markPartialJSON("Expected ']' at end of array")
}
index++
return array
}
const parseNum = (): unknown => {
if (index === 0) {
if (jsonString === "-") throwMalformedError("Not sure what '-' is")
try {
return decodeJson(jsonString)
} catch (error) {
if (Allow.NUM & allow) {
try {
return decodeJson(jsonString.substring(0, jsonString.lastIndexOf("e")))
} catch {}
}
throwMalformedError(String(error))
}
}
const start = index
if (jsonString[index] === "-") index++
while (jsonString[index] && !",]}".includes(jsonString[index])) index++
if (index === length && !(Allow.NUM & allow)) markPartialJSON("Unterminated number literal")
try {
return decodeJson(jsonString.substring(start, index))
} catch (error) {
if (jsonString.substring(start, index) === "-") markPartialJSON("Not sure what '-' is")
try {
return decodeJson(jsonString.substring(start, jsonString.lastIndexOf("e")))
} catch {
throwMalformedError(String(error))
}
}
}
const skipBlank = () => {
while (index < length && " \n\r\t".includes(jsonString[index])) index++
}
return parseAny()
}
export const parse = parseJSON
-5
View File
@@ -155,11 +155,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
+1 -1
View File
@@ -66,7 +66,7 @@ describe("request option precedence", () => {
expect(prepared.body).toMatchObject({
model: "gpt-4o-mini",
stream: true,
max_completion_tokens: 30,
max_tokens: 30,
temperature: 0.5,
top_p: 0.9,
frequency_penalty: 0.25,
@@ -7,13 +7,7 @@
"route": "cloudflare-workers-ai",
"transport": "http",
"model": "@cf/openai/gpt-oss-20b",
"tags": [
"prefix:cloudflare-workers-ai",
"provider:cloudflare-workers-ai",
"tool",
"tool-call",
"golden"
]
"tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"]
},
"interactions": [
{
@@ -24,7 +18,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\": \"@cf/openai/gpt-oss-20b\", \"messages\": [{\"role\": \"system\", \"content\": \"Call tools exactly as requested.\"}, {\"role\": \"user\", \"content\": \"Call get_weather with city exactly Paris.\"}], \"tools\": [{\"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"description\": \"Get current weather for a city.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}, \"required\": [\"city\"], \"additionalProperties\": false}, \"strict\": false}}], \"tool_choice\": {\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}, \"stream\": true, \"stream_options\": {\"include_usage\": true}, \"max_tokens\": 120, \"temperature\": 0}"
"body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}"
},
"response": {
"status": 200,
@@ -35,4 +29,4 @@
}
}
]
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-43
View File
@@ -1,43 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Allow, MalformedJSON, PartialJSON, parse } from "../src/protocols/utils/partial-json.js"
describe("partial JSON", () => {
test("parses complete JSON", () => {
expect(parse('{"key":"value","items":[1,true,null]}')).toEqual({
key: "value",
items: [1, true, null],
})
const object = parse('{"__proto__":{"safe":true}}') as Record<string, unknown>
expect(Object.hasOwn(object, "__proto__")).toBe(true)
})
test("parses partial strings", () => {
expect(parse('"hello')).toBe("hello")
expect(parse('"hello \\u12')).toBe("hello ")
expect(() => parse('"hello', ~Allow.STR)).toThrow(PartialJSON)
})
test("controls partial collection values independently", () => {
expect(parse('["', Allow.ARR)).toEqual([])
expect(parse('["', Allow.ARR | Allow.STR)).toEqual([""])
expect(parse('{"key":"', Allow.OBJ)).toEqual({})
expect(parse('{"key":"', Allow.OBJ | Allow.STR)).toEqual({ key: "" })
})
test("parses partial literals and numbers", () => {
expect(parse("nu", Allow.NULL)).toBeNull()
expect(parse("tr", Allow.BOOL)).toBe(true)
expect(parse("fa", Allow.BOOL)).toBe(false)
expect(parse("1e", Allow.NUM)).toBe(1)
})
test("distinguishes disallowed partial values from malformed values", () => {
expect(() => parse("[", Allow.STR)).toThrow(PartialJSON)
expect(() => parse("n", ~Allow.NULL)).toThrow(MalformedJSON)
})
test("rejects empty input", () => {
expect(() => parse(" ")).toThrow("is empty")
})
})
@@ -18,15 +18,6 @@ const opus48 = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-opus-4-8" })
const compileUnsignedReasoning = (model: LLMRequest["model"]) =>
compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "reasoning", text: "unsigned reasoning" }])],
cache: "none",
}),
)
const request = LLM.request({
id: "req_1",
model,
@@ -573,66 +564,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("demotes unsigned reasoning when signatures are required", () =>
Effect.gen(function* () {
const prepared = yield* compileUnsignedReasoning(model)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
}),
)
it.effect("infers empty-signature compatibility across Kimi providers", () =>
Effect.gen(function* () {
const coding = AnthropicMessages.route
.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://compatible.test/v1/" },
auth: Auth.header("x-api-key", "test"),
})
const moonshot = AnthropicMessages.route
.with({
provider: "moonshotai",
endpoint: { baseURL: "https://api.moonshot.ai/anthropic" },
auth: Auth.bearer("test"),
})
.model({ id: "kimi-k2.6" })
const codingPrepared = yield* compileUnsignedReasoning(coding.model({ id: "k3" }))
const moonshotPrepared = yield* compileUnsignedReasoning(moonshot)
expect(codingPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
expect(moonshotPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
}),
)
it.effect("lets an explicit signature requirement override inference", () =>
Effect.gen(function* () {
const compatible = AnthropicMessages.route
.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://api.kimi.com/coding/v1/" },
auth: Auth.header("x-api-key", "test"),
})
.model({ id: "k3", compatibility: { requireSignature: true } })
const prepared = yield* compileUnsignedReasoning(compatible)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
}),
)
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
-48
View File
@@ -906,54 +906,6 @@ describe("Gemini route", () => {
}),
)
it.effect("ignores unknown response parts", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ text: "Hello " },
{ executableCode: { language: "PYTHON", code: "print('ignored')" } },
{ text: "world" },
],
},
finishReason: "STOP",
},
],
}),
),
),
)
expect(response.text).toBe("Hello world")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
}),
)
it.effect("rejects malformed recognized response parts", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [{ content: { role: "model", parts: [{ text: 42 }] } }],
}),
),
),
Effect.flip,
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Invalid google/gemini stream event")
}),
)
it.effect("preserves thoughtSignature for reasoning and tool-call continuation", () =>
Effect.gen(function* () {
const body = sseEvents({
@@ -47,7 +47,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are concise." },
@@ -55,8 +55,7 @@ describe("OpenAI Chat route", () => {
],
stream: true,
stream_options: { include_usage: true },
store: false,
max_completion_tokens: 20,
max_tokens: 20,
temperature: 0,
})
}),
@@ -326,7 +325,7 @@ describe("OpenAI Chat route", () => {
}),
)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
messages: [
{ role: "user", content: "What is the weather?" },
@@ -346,7 +345,6 @@ describe("OpenAI Chat route", () => {
tools: [],
stream: true,
stream_options: { include_usage: true },
store: false,
})
}),
)
@@ -70,7 +70,7 @@ describe("OpenAI-compatible Chat route", () => {
baseURL: "https://api.deepseek.test/v1/",
query: { "api-version": "2026-01-01" },
})
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "system", content: "You are concise." },
@@ -79,7 +79,7 @@ describe("OpenAI-compatible Chat route", () => {
tools: [
{
type: "function",
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" }, strict: false },
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } },
},
],
tool_choice: "required",
@@ -130,7 +130,7 @@ describe("OpenAI-compatible Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "system", content: "You are concise." },
@@ -158,29 +158,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("enables ZAI tool streaming except for GLM 4.5 models", () =>
Effect.gen(function* () {
const prepare = (provider: string, baseURL: string, id: string) =>
compileRequest(
LLM.request({
model: OpenAICompatibleChat.route.with({ provider, endpoint: { baseURL } }).model({ id }),
prompt: "Use a tool.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: {} })],
}),
)
const current = yield* prepare("zai", "https://api.z.ai/api/paas/v4", "glm-4.7")
expect(current.body).toMatchObject({ tool_stream: true })
const legacy = yield* Effect.all(
["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"].map((id) =>
prepare("zhipuai", "https://open.bigmodel.cn/api/paas/v4", id),
),
)
legacy.forEach((item) => expect(item.body).not.toHaveProperty("tool_stream"))
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -203,7 +180,7 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "user", content: "What is the weather?" },
@@ -227,7 +204,6 @@ describe("OpenAI-compatible Chat route", () => {
name: "lookup",
description: "Lookup data",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
strict: false,
},
},
],
@@ -132,40 +132,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("lowers canonical parallel tool control", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Read the file.",
tools: [
ToolDefinition.make({
name: "read",
description: "Read a file.",
inputSchema: { type: "object" },
}),
],
toolChoice: { type: "auto", disableParallelToolUse: true },
}),
)
expect(prepared.body.parallel_tool_calls).toBe(false)
expect(prepared.body.tools).toEqual([
{
type: "function",
name: "read",
description: "Read a file.",
parameters: { type: "object" },
strict: false,
},
])
}),
)
it.effect("keeps foreign item id grammars but drops malformed ids", () =>
Effect.gen(function* () {
const model = configure({
@@ -258,31 +258,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("maps the canonical parallel tool setting with provider-option precedence", () =>
Effect.gen(function* () {
const disabled = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: true },
}),
)
const enabled = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: false },
}),
)
const overridden = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: true },
providerOptions: { parallelToolCalls: true },
}),
)
expect(disabled.body.parallel_tool_calls).toBe(false)
expect(enabled.body.parallel_tool_calls).toBe(true)
expect(overridden.body.parallel_tool_calls).toBe(true)
}),
)
it.effect("lowers chronological system updates to developer messages in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+1 -1
View File
@@ -101,7 +101,7 @@ describe("LLMClient tools", () => {
const messages = Reflect.get(second, "messages")
const tools = Reflect.get(second, "tools")
expect(Reflect.get(second, "max_completion_tokens")).toBe(50)
expect(Reflect.get(second, "max_tokens")).toBe(50)
expect(Reflect.get(second, "tool_choice")).toBe("auto")
expect(tools).toHaveLength(1)
expect(
@@ -20,47 +20,45 @@ const messages = [
},
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
] satisfies SessionMessageInfo[]
const session = {
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
}
const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
pageMessages: () => ({ items: messages }),
}
test("reverts directly to the selected user message", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
await mockOpenCodeServer(page, {
...fixture,
sessions: [session],
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
},
],
pageMessages: () => ({ items: messages }),
onRevertStage: (input) => staged.push(input),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -79,19 +77,3 @@ test("reverts directly to the selected user message", async ({ page }) => {
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
expect(staged).toEqual([{ sessionID, messageID: "msg_second" }])
})
test("hides revert actions in a child session", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
sessions: [
{ ...session, id: "ses_parent", slug: "parent", title: "Parent session" },
{ ...session, parentID: "ses_parent" },
],
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Session message revert")
const message = page.locator('[data-message-id="msg_second"]')
await message.hover()
await expect(message.getByRole("button", { name: "Revert message" })).toHaveCount(0)
})
@@ -126,8 +126,6 @@ test("routes typing to the composer unless the open terminal is focused", async
const composer = page.locator('[data-component="composer-editor"]')
const terminal = page.locator('[data-component="terminal"]')
await composer.click()
await expect(composer).toBeFocused()
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
+8 -1
View File
@@ -274,8 +274,15 @@ async function sendCommand(
const request = await buildSubmissionRequest(session, value)
await session.api.command({
sessionID: session.id,
id: value.id,
command: command.command,
text: command.arguments,
arguments: command.arguments,
agent: value.selection.agent,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
@@ -193,9 +193,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const openTerminal = () => {
actions.session.layout.view().terminal.open()
if (terminal.all().length > 0) terminal.new({ focus: true })
if (terminal.all().length === 0) terminal.requestFocus()
actions.session.layout.view().terminal.open()
}
const closeTerminal = () => {
@@ -361,8 +361,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
actions.session.layout.view().terminal.close()
return
}
actions.session.layout.view().terminal.open()
terminal.requestFocus(terminal.active())
actions.session.layout.view().terminal.open()
},
}),
viewCommand({
+1 -5
View File
@@ -37,11 +37,7 @@ export function createActiveComposerAdapter(input: {
current: () => data.session.get(id),
admitted: (messageID) => data.session.input.has(id, messageID) || !!data.session.message.get(id, messageID),
}),
interrupt: () =>
server.api.session
.interrupt({ sessionID: id, continue: true })
.then(() => undefined)
.catch(() => undefined),
interrupt: () => server.api.session.interrupt({ sessionID: id, continue: true }).catch(() => undefined),
}
return adapter
}
+1 -8
View File
@@ -156,7 +156,6 @@ export function createActiveSessionRegion(input: {
session: input.session,
setActiveMessage: input.timeline.actions.setActiveMessage,
})
const revertMessage: NonNullable<SessionUserActions["revert"]> = ({ messageID }) => revert.to(messageID)
useComposerCommands()
useSessionCommands({
session: input.session,
@@ -179,13 +178,7 @@ export function createActiveSessionRegion(input: {
return {
actions: {
timeline: {
get revert() {
if (input.session.data.isChild()) return
return revertMessage
},
openAttachment,
} satisfies SessionUserActions,
timeline: { revert: ({ messageID }) => revert.to(messageID), openAttachment } satisfies SessionUserActions,
},
region: {
centered: input.screen.centered,
-6
View File
@@ -76,7 +76,6 @@ export async function streamTurn(input: {
readonly cwd: string
readonly start: TurnStart
readonly writeTextFile: boolean
readonly action?: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
@@ -346,11 +345,6 @@ export async function streamTurn(input: {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
})
if (input.action) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
}
if (control.cancelled) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (!started) {
+2 -2
View File
@@ -326,7 +326,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
cwd: state.cwd,
start: prepared.start,
writeTextFile: capabilities.writeTextFile,
action: prepared.command !== undefined,
control,
connectionSignal: input.connection.signal,
sessionSignal: state.abort.signal,
@@ -378,8 +377,9 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
return client.session.command(
{
sessionID: session.id,
id: prompt.start.id,
command: prompt.command.name,
text: prompt.slash?.args ?? "",
arguments: prompt.slash?.args,
files: prompt.files,
delivery: "steer",
},
+1 -2
View File
@@ -601,7 +601,6 @@ describe("acp event behavior", () => {
},
onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
return true
},
})
const result = streamTurn({
@@ -625,7 +624,7 @@ describe("acp event behavior", () => {
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
control.cancelled = true
control.admission.abort()
expect(await fixture.client.session.interrupt({ sessionID: "ses_cancel" })).toEqual({ interrupted: true })
await fixture.client.session.interrupt({ sessionID: "ses_cancel" })
const response = await withTimeout(result, "cancelled turn did not terminate")
expect(response).toMatchObject({ stopReason: "cancelled" })
-39
View File
@@ -121,42 +121,3 @@ test("acp prompt resolves after ordered turn updates", async () => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
})
test("acp action resolves without prompt lifecycle events", async () => {
const encoder = new TextEncoder()
const server = Bun.serve({
port: 0,
fetch(request) {
if (new URL(request.url).pathname !== "/api/event") return new Response(null, { status: 404 })
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "server.connected", data: {} })}\n\n`))
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
try {
const response = await streamTurn({
client: OpenCode.make({ baseUrl: server.url.toString() }),
connection: {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
sessionID: "ses_test",
cwd: "/workspace",
start: { type: "input", id: "msg_action" },
writeTextFile: false,
action: true,
control: { cancelled: false, admission: new AbortController() },
submit: async () => {},
})
expect(response).toMatchObject({ stopReason: "end_turn" })
} finally {
await server.stop(true)
}
})
+1
View File
@@ -87,6 +87,7 @@ export const planAgent = {
export const reviewCommand = {
name: "review",
description: "Review changes",
template: "",
} satisfies CommandInfo
export const verifySkill = {
+9 -2
View File
@@ -12,7 +12,13 @@ describe("acp service prompt routing and usage", () => {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
return new Response(null, { status: 204 })
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_routes", inboxID: id },
})
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/skill") {
const id = requestID(request)
@@ -59,8 +65,9 @@ describe("acp service prompt routing and usage", () => {
const skill = fixture.requests.find((request) => request.path === "/api/session/ses_routes/skill")
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
expect(command?.body).toMatchObject({
id: expect.any(String),
command: "review",
text: "now",
arguments: "now",
files: [],
delivery: "steer",
})
+3 -4
View File
@@ -20,7 +20,7 @@ type FixtureOptions = {
readonly onInterrupt?: (input: {
readonly sessionID: string
readonly send: (event: unknown) => void
}) => boolean | Promise<boolean>
}) => void | Promise<void>
readonly onPermissionReply?: (input: {
readonly sessionID: string
readonly requestID: string
@@ -152,9 +152,8 @@ export function createSseFixture(options: FixtureOptions = {}) {
const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
if (interrupt?.[1]) {
const interrupted =
(await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })) ?? false
return Response.json({ interrupted })
await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
return new Response(null, { status: 204 })
}
return new Response(null, { status: 404 })
+1 -1
View File
@@ -95,7 +95,7 @@ await Effect.runPromise(
),
write(
emitEffectImported(effectContract, {
module: "../../contract",
module: "../../contract.js",
api: "ClientApi",
shapeModule: "../api/api.js",
}),
+12 -22
View File
@@ -255,14 +255,18 @@ export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => E
export type SessionCommandInput = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
readonly text: string
readonly arguments?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type SessionCommandOutput = void
export type SessionCommandOutput = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: SessionCommandInput) => Effect.Effect<SessionCommandOutput, E>
export type SessionSkillInput = {
@@ -998,7 +1002,7 @@ export type SessionLogOutput =
export type SessionLogOperation<E = never> = (input: SessionLogInput) => Stream.Stream<SessionLogOutput, E>
export type SessionInterruptInput = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type SessionInterruptOutput = { readonly interrupted: boolean }
export type SessionInterruptOutput = void
export type SessionInterruptOperation<E = never> = (
input: SessionInterruptInput,
) => Effect.Effect<SessionInterruptOutput, E>
@@ -1108,7 +1112,11 @@ export interface ModelApi<E = never> {
readonly default: ModelDefaultOperation<E>
}
export type GenerateTextInput = { readonly prompt: string; readonly model?: Model.Ref | undefined }
export type GenerateTextInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly prompt: string
readonly model?: Model.Ref | undefined
}
export type GenerateTextOutput = { readonly text: string }
export type GenerateTextOperation<E = never> = (input: GenerateTextInput) => Effect.Effect<GenerateTextOutput, E>
@@ -1691,23 +1699,6 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type WorkspaceCreateInput = { readonly id?: Workspace.ID | undefined; readonly provider: string }
export type WorkspaceCreateOutput = Workspace.ID
export type WorkspaceCreateOperation<E = never> = (
input: WorkspaceCreateInput,
) => Effect.Effect<WorkspaceCreateOutput, E>
export type WorkspaceDestroyInput = { readonly workspaceID: Workspace.ID }
export type WorkspaceDestroyOutput = Workspace.DestroyResult
export type WorkspaceDestroyOperation<E = never> = (
input: WorkspaceDestroyInput,
) => Effect.Effect<WorkspaceDestroyOutput, E>
export interface WorkspaceApi<E = never> {
readonly create: WorkspaceCreateOperation<E>
readonly destroy: WorkspaceDestroyOperation<E>
}
export type VcsGetInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1825,7 +1816,6 @@ export interface AppApi<E = never> {
readonly shell: ShellApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
readonly workspace: WorkspaceApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
+14 -27
View File
@@ -3,7 +3,7 @@ import { Effect, Stream, Schema } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import { ClientApi } from "../../contract.js"
import type {
HealthGetOutput,
ServerGetOutput,
@@ -214,10 +214,6 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
WorkspaceCreateInput,
WorkspaceCreateOutput,
WorkspaceDestroyInput,
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
@@ -444,14 +440,21 @@ const EndpointSessionCommand = (raw: RawClient["server.session"]) => (input: Ses
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
id: input["id"],
command: input["command"],
text: input["text"],
arguments: input["arguments"],
agent: input["agent"],
model: input["model"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
resume: input["resume"],
},
}).pipe(Effect.mapError(mapClientError)),
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionSkill = (raw: RawClient["server.session"]) => (input: SessionSkillInput) =>
@@ -721,7 +724,10 @@ const adaptGroupModel = (raw: RawClient["server.model"]) => ({
const EndpointGenerateText = (raw: RawClient["server.generate"]) => (input: GenerateTextInput) =>
preserveEffect<GenerateTextOutput>()(
raw["generate.text"]({ payload: { prompt: input["prompt"], model: input["model"] } }).pipe(
raw["generate.text"]({
query: { location: input["location"] },
payload: { prompt: input["prompt"], model: input["model"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -1272,24 +1278,6 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
refresh: EndpointWorktreeRefresh(raw),
})
const EndpointWorkspaceCreate = (raw: RawClient["server.workspace"]) => (input: WorkspaceCreateInput) =>
preserveEffect<WorkspaceCreateOutput>()(
raw["workspace.create"]({ payload: { id: input["id"], provider: input["provider"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointWorkspaceDestroy = (raw: RawClient["server.workspace"]) => (input: WorkspaceDestroyInput) =>
preserveEffect<WorkspaceDestroyOutput>()(
raw["workspace.destroy"]({ params: { workspaceID: input["workspaceID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupWorkspace = (raw: RawClient["server.workspace"]) => ({
create: EndpointWorkspaceCreate(raw),
destroy: EndpointWorkspaceDestroy(raw),
})
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
preserveEffect<VcsGetOutput>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1380,7 +1368,6 @@ const adaptClient = (raw: RawClient) => ({
shell: adaptGroupShell(raw["server.shell"]),
reference: adaptGroupReference(raw["server.reference"]),
worktree: adaptGroupWorktree(raw["server.worktree"]),
workspace: adaptGroupWorkspace(raw["server.workspace"]),
vcs: adaptGroupVcs(raw["server.vcs"]),
debug: adaptGroupDebug(raw["server.debug"]),
migration: adaptGroupMigration(raw["server.migration"]),
+2 -2
View File
@@ -2,7 +2,7 @@
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
import type { Effect } from "effect"
export * from "./generated/index"
export * from "./generated/index.js"
export type {
AgentApi,
AppApi,
@@ -47,4 +47,4 @@ export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"
export { PromptInput } from "@opencode-ai/schema/prompt-input"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client.js").make>>
+13 -37
View File
@@ -210,10 +210,6 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
WorkspaceCreateInput,
WorkspaceCreateOutput,
WorkspaceDestroyInput,
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
@@ -635,24 +631,28 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
request<SessionCommandOutput>(
request<{ readonly data: SessionCommandOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
body: {
id: input["id"],
command: input["command"],
text: input["text"],
arguments: input["arguments"],
agent: input["agent"],
model: input["model"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
resume: input["resume"],
},
successStatus: 204,
declaredStatuses: [404, 500, 400, 401],
empty: true,
successStatus: 200,
declaredStatuses: [409, 400, 404, 500, 401],
empty: false,
},
requestOptions,
),
).then((value) => value.data),
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
request<SessionSkillOutput>(
{
@@ -880,9 +880,9 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
query: { continue: input["continue"] },
successStatus: 200,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: false,
empty: true,
},
requestOptions,
),
@@ -979,6 +979,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/generate`,
query: { location: input["location"] },
body: { prompt: input["prompt"], model: input["model"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
@@ -1769,31 +1770,6 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
workspace: {
create: (input: WorkspaceCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: WorkspaceCreateOutput }>(
{
method: "POST",
path: `/api/workspace`,
body: { id: input["id"], provider: input["provider"] },
successStatus: 200,
declaredStatuses: [409, 404, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
destroy: (input: WorkspaceDestroyInput, requestOptions?: RequestOptions) =>
request<WorkspaceDestroyOutput>(
{
method: "DELETE",
path: `/api/workspace/${encodeURIComponent(input.workspaceID)}`,
successStatus: 200,
declaredStatuses: [500, 401, 400],
empty: false,
},
requestOptions,
),
},
vcs: {
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
+142 -31
View File
@@ -176,8 +176,6 @@ export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?:
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type SessionInterruptResponse = { interrupted: boolean }
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
@@ -313,8 +311,6 @@ export type PermissionSavedInfo = { id: string; projectID: string; action: strin
export type FileSystemEntry = { path: string; type: "file" | "directory" }
export type CommandInfo = { name: string; description?: string }
export type SkillInfo = {
id: string
name: string
@@ -382,8 +378,6 @@ export type WorktreeDirectory = { directory: string; strategy?: string }
export type WorktreeInfo = { directory: string }
export type WorkspaceDestroyResult = { destroyed: boolean }
export type VcsBranch = { current?: string; default?: string }
export type VcsFileStatus = {
@@ -397,6 +391,15 @@ export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
export type CommandInfo = {
name: string
template: string
description?: string
agent?: string
model?: ModelRef
subtask?: boolean
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
@@ -2217,13 +2220,13 @@ export type CommandNotFoundError = {
export const isCommandNotFoundError = (value: unknown): value is CommandNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandNotFoundError"
export type CommandExecutionError = {
readonly _tag: "CommandExecutionError"
export type CommandEvaluationError = {
readonly _tag: "CommandEvaluationError"
readonly command: string
readonly message: string
}
export const isCommandExecutionError = (value: unknown): value is CommandExecutionError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandExecutionError"
export const isCommandEvaluationError = (value: unknown): value is CommandEvaluationError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandEvaluationError"
export type SkillNotFoundError = {
readonly _tag: "SkillNotFoundError"
@@ -3634,9 +3637,35 @@ export type SessionPromptOutput = { data: SessionInboxUser }["data"]
export type SessionCommandInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["id"]
readonly command: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3652,10 +3681,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["command"]
readonly text: {
readonly arguments?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3671,10 +3704,60 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
}["text"]
readonly resume?: boolean | null
}["arguments"]
readonly agent?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["agent"]
readonly model?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["model"]
readonly files?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3690,10 +3773,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["files"]
readonly agents?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3709,10 +3796,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["agents"]
readonly skills?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3728,10 +3819,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["skills"]
readonly delivery?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3747,10 +3842,34 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["delivery"]
readonly resume?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionCommandOutput = void
export type SessionCommandOutput = { data: SessionInboxUser }["data"]
export type SessionSkillInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3934,7 +4053,7 @@ export type SessionInterruptInput = {
readonly continue?: { readonly continue?: boolean | undefined }["continue"]
}
export type SessionInterruptOutput = SessionInterruptResponse
export type SessionInterruptOutput = void
export type SessionBackgroundInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -4005,6 +4124,9 @@ export type ModelDefaultOutput = {
}
export type GenerateTextInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly prompt: {
readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
@@ -5630,17 +5752,6 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
export type WorktreeRefreshOutput = void
export type WorkspaceCreateInput = {
readonly id?: { readonly id?: string | undefined; readonly provider: string }["id"]
readonly provider: { readonly id?: string | undefined; readonly provider: string }["provider"]
}
export type WorkspaceCreateOutput = { data: string }["data"]
export type WorkspaceDestroyInput = { readonly workspaceID: { readonly workspaceID: string }["workspaceID"] }
export type WorkspaceDestroyOutput = WorkspaceDestroyResult
export type VcsGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+3 -11
View File
@@ -172,14 +172,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
}
if (request.method === "POST") {
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
request.url.includes("/interrupt")
? Response.json({ interrupted: true })
: new Response(null, { status: 204 }),
),
)
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
}
return Effect.succeed(
HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
@@ -209,12 +202,12 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const log = yield* client.session
.log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) })
.pipe(Stream.runCollect)
const interrupted = yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
const message = yield* client.session.message({
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"),
})
return { page, active, created, admitted, context, log, interrupted, message }
return { page, active, created, admitted, context, log, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
const listed = result.page.data[0]
@@ -223,7 +216,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(result.interrupted).toEqual({ interrupted: true })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test")
+1 -34
View File
@@ -30,7 +30,6 @@ test("exposes every standard HTTP API group", () => {
"question",
"reference",
"worktree",
"workspace",
"vcs",
"debug",
"migration",
@@ -82,21 +81,6 @@ test("config.get returns ordered config entries for a location", async () => {
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("generate.text uses the locationless public contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: { text: "pong" } })
},
})
expect(await client.generate.text({ prompt: "ping" })).toEqual({ text: "pong" })
expect(request?.url).toBe("http://localhost:3000/api/generate")
expect(await request?.json()).toEqual({ prompt: "ping" })
})
test("websearch.query uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
@@ -296,21 +280,6 @@ test("worktree methods use the global project contract", async () => {
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
})
test("workspace.destroy returns the transition result", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ destroyed: false })
},
})
expect(await client.workspace.destroy({ workspaceID: "wrk_missing" })).toEqual({ destroyed: false })
expect(request?.method).toBe("DELETE")
expect(request?.url).toBe("http://localhost:3000/api/workspace/wrk_missing")
})
test("shell list and remove use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const shell = {
@@ -547,7 +516,6 @@ test("session methods use the public HTTP contract", async () => {
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (url.includes("/interrupt")) return Response.json({ interrupted: true })
if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } })
},
@@ -579,7 +547,7 @@ test("session methods use the public HTTP contract", async () => {
const context = await client.session.context({ sessionID: "ses_test" })
const log = []
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
const interrupted = await client.session.interrupt({ sessionID: "ses_test", continue: true })
await client.session.interrupt({ sessionID: "ses_test", continue: true })
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
@@ -588,7 +556,6 @@ test("session methods use the public HTTP contract", async () => {
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(generated.text).toBe("A transient answer")
expect(interrupted).toEqual({ interrupted: true })
expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" })
expect(context).toEqual([])
expect(log).toEqual([modelSwitchedEvent, synced])
+223 -68
View File
@@ -1,32 +1,26 @@
export * as Command from "./command.js"
import { Command } from "@opencode-ai/schema/command"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Bus } from "./bus.js"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state.js"
import { MCP } from "./mcp/index.js"
import { Bus } from "./bus.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { Location } from "./location.js"
import { ShellSelect } from "./shell/select.js"
export const Info = Command.Info
export type Info = Command.Info
export { Event } from "@opencode-ai/schema/command"
export interface Invocation {
readonly sessionID: Session.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
export type Evaluation = {
readonly text: string
}
export interface Definition {
readonly name: string
readonly description?: string
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
}
export type Draft = {
add: (definition: Definition) => void
export type Data = {
commands: Map<string, Types.DeepMutable<Info>>
}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.NotFoundError", {
@@ -34,73 +28,234 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.
message: Schema.String,
}) {}
export class ExecutionError extends Schema.TaggedError<ExecutionError>()("Command.ExecutionError", {
export class EvaluationError extends Schema.TaggedError<EvaluationError>()("Command.EvaluationError", {
command: Schema.String,
message: Schema.String,
}) {}
export type Draft = {
list: () => readonly Info[]
get: (name: string) => Info | undefined
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
remove: (name: string) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
readonly execute: (input: {
readonly evaluate: (input: {
readonly name: string
readonly invocation: Invocation
}) => Effect.Effect<void, NotFoundError | ExecutionError>
readonly arguments?: string
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = State.create<Map<string, Definition>, Draft>({
name: "command",
initial: () => new Map(),
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
name: definition.name,
description: definition.description,
const layer = () =>
Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const processes = yield* AppProcess.Service
const location = yield* Location.Service
const shell = yield* ShellSelect.Service
const state = State.create<Data, Draft>({
name: "command",
initial: () => ({ commands: new Map() }),
draft: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current)
current.name = name
},
remove: (name) => {
draft.commands.delete(name)
},
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
const mcpCommands = Effect.fnUntraced(function* () {
return (yield* mcp.prompts()).map((prompt) =>
Info.make({
name: mcpCommandName(prompt.server, prompt.name),
template: "",
description: prompt.description,
}),
)
})
return Service.of({
reload: state.reload,
transform: state.transform,
get: Effect.fn("Command.get")((name) =>
Effect.sync(() => {
const definition = state.get().get(name)
return definition ? info(definition) : undefined
return Service.of({
reload: state.reload,
transform: state.transform,
get: Effect.fn("Command.get")(function* (name) {
const command = staticCommand(name)
if (command) return command
return (yield* mcpCommands()).find((command) => command.name === name)
}),
),
list: Effect.fn("Command.list")(() => Effect.sync(() => Array.from(state.get().values(), info))),
execute: Effect.fn("Command.execute")(function* (input) {
const definition = state.get().get(input.name)
if (!definition)
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
return yield* definition.execute(input.invocation).pipe(
Effect.tapError((error) => Effect.logError("command execution failed", { command: input.name, error })),
Effect.mapError((error) => new ExecutionError({ command: input.name, message: errorMessage(error) })),
list: Effect.fn("Command.list")(function* () {
const commands = Array.from(state.get().commands.values()) as Info[]
const names = new Set(commands.map((command) => command.name))
return [...commands, ...(yield* mcpCommands()).filter((command) => !names.has(command.name))]
}),
evaluate: Effect.fn("Command.evaluate")(function* (input) {
const command = staticCommand(input.name)
if (command)
return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
location,
processes,
shell,
})
const prompt = (yield* mcp.prompts()).find(
(prompt) => mcpCommandName(prompt.server, prompt.name) === input.name,
)
if (!prompt)
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
const result = yield* mcp
.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(
(prompt.arguments ?? []).map((argument, index) => [
argument.name,
parseArguments(input.arguments ?? "")[index] ?? "",
]),
),
})
.pipe(
Effect.catchTag("MCP.NotFoundError", () =>
Effect.fail(
new EvaluationError({
command: input.name,
message: `MCP server could not be found while evaluating prompt: ${prompt.server}`,
}),
),
),
)
if (!result)
return yield* new EvaluationError({
command: input.name,
message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`,
})
return {
text: result.messages
.map((message) => promptMessageText(message.content))
.join("\n")
.trim(),
}
}),
})
}),
)
function evaluateTemplate(
command: string,
template: string,
input: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const expanded = evaluateArguments(template, input)
return { text: yield* evaluateShell(command, expanded, services) }
})
}
function evaluateArguments(template: string, input: string) {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim())
return `${withArguments}\n\n${input}`.trim()
return withArguments.trim()
}
const evaluateShell = Effect.fnUntraced(function* (
command: string,
text: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.preferred()
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{
combineOutput: true,
},
)
}),
})
}),
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) =>
new EvaluationError({
command,
message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`,
}),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
function promptMessageText(content: unknown) {
if (typeof content === "string") return content
if (!content || typeof content !== "object") return ""
if (!("type" in content) || content.type !== "text") return ""
if (!("text" in content) || typeof content.text !== "string") return ""
return content.text
}
function mcpCommandName(server: string, prompt: string) {
return `${sanitize(server)}:${sanitize(prompt)}`
}
function sanitize(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node],
layer: layer(),
deps: [MCP.node, Bus.node, AppProcess.node, Location.node, ShellSelect.node],
})
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
if (error && typeof error === "object" && "message" in error && typeof error.message === "string")
return error.message
return "Command execution failed"
}
+12 -109
View File
@@ -1,18 +1,12 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Command } from "../../command.js"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
@@ -29,9 +23,6 @@ export const Plugin = define({
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
@@ -60,41 +51,17 @@ export const Plugin = define({
yield* ctx.command.transform((draft) => {
for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.add({
name,
description: command.description,
execute: (input) =>
Effect.gen(function* () {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined
? {}
: { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
config,
location,
processes,
shell,
}),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
draft.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined)
item.model = {
id: command.model.model,
providerID: command.model.providerID,
...(command.model.variant === undefined ? {} : { variant: command.model.variant }),
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
}
@@ -147,67 +114,3 @@ function decode(directory: string, filepath: string, content: string) {
info,
}
}
function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.preferred()
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+1 -1
View File
@@ -1,7 +1,7 @@
export * as Watcher from "./watcher.js"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import { createWrapper } from "@parcel/watcher/wrapper.js"
import type ParcelWatcher from "@parcel/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
+3 -4
View File
@@ -2,7 +2,7 @@ export * as MCP from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import { Command } from "@opencode-ai/schema/command"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
@@ -19,7 +19,6 @@ import { State } from "../state.js"
import type { MCPClient } from "./client.js"
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
export type ServerName = typeof ServerName.Type
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
@@ -454,7 +453,7 @@ export const layer = (options?: Options) =>
Effect.map((defs) => {
entry.prompts = defs.map((def) => toPrompt(name, def))
}),
Effect.andThen(bus.publish(PromptsChanged, { server: name })),
Effect.andThen(bus.publish(Command.Event.Updated, {})),
)
// Runs a connection callback under the server lock, dropping it if the connection is no longer
@@ -573,7 +572,7 @@ export const layer = (options?: Options) =>
yield* Scope.close(scope, Exit.void)
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(PromptsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
})
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
+7 -98
View File
@@ -1,10 +1,8 @@
export * as CommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Effect } from "effect"
import { Location } from "../location.js"
import { MCP } from "../mcp/index.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
@@ -12,104 +10,15 @@ export const Plugin = define({
id: "opencode.command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const loaded = { prompts: [] as MCP.Prompt[] }
yield* bus
.subscribe(MCP.PromptsChanged)
.pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
loaded.prompts = yield* mcp.prompts()
yield* ctx.command.transform((draft) => {
draft.add({
name: "init",
description: "guided AGENTS.md setup",
execute: (input) =>
ctx.session
.prompt({
...input.prompt,
sessionID: input.sessionID,
text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.asVoid),
draft.update("init", (command) => {
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
command.description = "guided AGENTS.md setup"
})
draft.add({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
execute: (input) =>
ctx.session
.prompt({
...input.prompt,
sessionID: input.sessionID,
text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.asVoid),
draft.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
})
for (const prompt of loaded.prompts) {
draft.add({
name: mcpCommandName(prompt.server, prompt.name),
description: prompt.description,
execute: (input) =>
Effect.gen(function* () {
const result = yield* mcp.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(
(prompt.arguments ?? []).map((argument, index) => [
argument.name,
parseArguments(input.prompt.text)[index] ?? "",
]),
),
})
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: result.messages
.map((message) => promptMessageText(message.content))
.join("\n")
.trim(),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
})
}
})
}),
})
function append(template: string, input: string) {
return [template, input.trim()].filter(Boolean).join("\n\n")
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((argument) => argument.replace(quoteTrimRegex, ""))
}
function promptMessageText(content: unknown) {
if (typeof content === "string") return content
if (!content || typeof content !== "object") return ""
if (!("type" in content) || content.type !== "text") return ""
if (!("text" in content) || typeof content.text !== "string") return ""
return content.text
}
function mcpCommandName(server: string, prompt: string) {
return `${sanitize(server)}:${sanitize(prompt)}`
}
function sanitize(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const quoteTrimRegex = /^["']|["']$/g
+1 -4
View File
@@ -402,10 +402,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
command: runtime.session.command,
rename: runtime.session.rename,
synthetic: runtime.session.synthetic,
interrupt: (input) =>
runtime.session
.interrupt(input.sessionID, { continue: input.continue })
.pipe(Effect.map((interrupted) => ({ interrupted }))),
interrupt: (input) => runtime.session.interrupt(input.sessionID),
wait: (input) => runtime.session.wait(input.sessionID),
},
} satisfies Plugin.Context
+1 -1
View File
@@ -236,7 +236,6 @@ const pre = [
MCPCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
@@ -275,6 +274,7 @@ const post = [
ConfigWebSearchPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
PlanPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
+6 -67
View File
@@ -1,7 +1,6 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import type { Server } from "node:http"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js"
@@ -13,9 +12,6 @@ import type { PluginInternal } from "../internal.js"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const callbackFallbackPort = 1457
const callbackBindAttempts = 10
const callbackBindRetryDelay = 200
const pollingSafetyMargin = 3000
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
@@ -59,10 +55,11 @@ const browser = (app: App.Info) =>
const pkce = yield* Effect.promise(generatePKCE)
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback`
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://localhost")
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (url.pathname !== "/auth/callback") {
response.writeHead(404).end("Not found")
return
@@ -89,9 +86,11 @@ const browser = (app: App.Info) =>
.writeHead(200, { "Content-Type": "text/html" })
.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
})
const port = yield* listen(server)
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, "localhost", () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
const redirect = `http://localhost:${port}/auth/callback`
return {
mode: "auto" as const,
url: authorizeURL(redirect, pkce, state),
@@ -105,66 +104,6 @@ const browser = (app: App.Info) =>
refresh: (value) => refresh(browserMethodID, value, app),
}) satisfies IntegrationOAuthMethodRegistration
function listen(server: Server) {
return bind(server, callbackPort).pipe(
Effect.as(callbackPort),
Effect.catchIf(addressInUse, () =>
cancel(callbackPort).pipe(
Effect.ignore,
Effect.andThen(Effect.sleep(callbackBindRetryDelay)),
Effect.andThen(bindWithRetry(server, callbackPort, callbackBindAttempts - 1)),
Effect.as(callbackPort),
Effect.catchIf(addressInUse, () =>
bindWithRetry(server, callbackFallbackPort, callbackBindAttempts).pipe(
Effect.as(callbackFallbackPort),
Effect.catchIf(addressInUse, () =>
Effect.fail(
new Error(
`OpenAI browser login needs local port ${callbackPort} or ${callbackFallbackPort}, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.`,
),
),
),
),
),
),
),
)
}
function bindWithRetry(server: Server, port: number, attempts: number): Effect.Effect<void, Error> {
return bind(server, port).pipe(
Effect.catchIf(
(error) => addressInUse(error) && attempts > 1,
() => Effect.sleep(callbackBindRetryDelay).pipe(Effect.andThen(bindWithRetry(server, port, attempts - 1))),
),
)
}
function bind(server: Server, port: number) {
return Effect.callback<void, Error>((resume) => {
const onError = (error: Error) => resume(Effect.fail(error))
server.once("error", onError)
server.listen(port, "localhost", () => {
server.off("error", onError)
resume(Effect.void)
})
})
}
function cancel(port: number) {
return Effect.tryPromise({
try: (signal) =>
fetch(`http://localhost:${port}/cancel`, {
signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]),
}),
catch: (cause) => cause,
})
}
function addressInUse(error: Error) {
return "code" in error && error.code === "EADDRINUSE"
}
const headless = (app: App.Info) =>
({
integrationID: Integration.ID.make("openai"),
+2 -4
View File
@@ -146,10 +146,8 @@ bug.
For questions about creating, configuring, loading, publishing, or migrating
plugins, fetch the full [plugins guide](https://opencode.ai/v2/docs/build/plugins)
before answering. Refer to this guide when the user wants to build a plugin. It
covers hooks, transforms, tools, plugin context capabilities, and package
entrypoints. Plugins can also extend the TUI; for those, fetch the
[CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli).
before answering. This includes questions about the Effect plugin API, hooks,
transforms, tools, plugin context capabilities, and package entrypoints.
## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service)
+44 -16
View File
@@ -246,14 +246,26 @@ export interface Interface {
prompt: string
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
readonly command: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
command: string
text: string
arguments?: string
agent?: Agent.ID
model?: Model.Ref
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
resume?: boolean
}) => Effect.Effect<
SessionInbox.User,
| NotFoundError
| PromptConflictError
| AttachmentError
| SkillNotFoundError
| Command.NotFoundError
| Command.EvaluationError
>
readonly shell: (input: {
id?: Event.ID
sessionID: SessionSchema.ID
@@ -272,7 +284,7 @@ export interface Interface {
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
readonly synthetic: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -643,19 +655,35 @@ const layer = Layer.effect(
yield* plugins.flush
return yield* Command.Service
}).pipe(Effect.provide(locations.get(session.location)))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery,
},
const command = yield* commands.get(input.command)
if (!command)
return yield* new Command.NotFoundError({
command: input.command,
message: `Command not found: ${input.command}`,
})
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
const agent = command.agent ?? input.agent
const commandAgent = yield* Effect.gen(function* () {
if (!command.agent) return undefined
const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* agents.get(Agent.ID.make(command.agent))
})
const model = command.model ?? commandAgent?.model ?? input.model
if (agent !== undefined && session.agent !== Agent.ID.make(agent))
yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) })
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
return yield* result.prompt({
id: input.id,
sessionID: input.sessionID,
text: evaluated.text,
files: input.files,
agents: input.agents,
skills: input.skills,
delivery: input.delivery,
resume: input.resume,
})
}),
shell: Effect.fn("Session.shell")(function* (input) {
+6 -8
View File
@@ -24,10 +24,9 @@ export interface Interface {
/**
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
* Returns whether an active execution was interrupted. Compose with `awaitIdle` when
* settlement matters.
* Compose with `awaitIdle` when settlement matters.
*/
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
@@ -141,8 +140,8 @@ export const layer = Layer.effect(
active: coordinator.active,
interrupt: (sessionID, options) =>
Effect.gen(function* () {
const interrupted = yield* coordinator.interrupt(sessionID, "user")
if (!options?.continue) return interrupted
yield* coordinator.interrupt(sessionID, "user")
if (!options?.continue) return
// Resume steering input and between-turn control work from the interrupted
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
// promotes them, and a control item behind a queued prompt waits its turn.
@@ -152,10 +151,9 @@ export const layer = Layer.effect(
// rows inside uninterruptible publications, so a steer row is either still
// promotable here or was fully delivered and needs no resumption.
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (next === undefined) return interrupted
if (next === undefined) return
if (next.delivery === "steer" || next.type === "compaction" || next.type === "move")
yield* coordinator.wake(sessionID, "steer")
return interrupted
}),
resume: coordinator.run,
wake: coordinator.wake,
@@ -177,7 +175,7 @@ export const noopLayer = Layer.succeed(
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
interrupt: () => Effect.void,
awaitIdle: () => Effect.void,
}),
)
+7 -8
View File
@@ -14,10 +14,9 @@ export interface Coordinator<Key, E, Reason = never> {
/**
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
* finalizers and settled hook on its own time. Returns whether an active execution was
* interrupted. Compose with `awaitIdle` for settlement.
* finalizers and settled hook on its own time. Compose with `awaitIdle` for settlement.
*/
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
@@ -135,16 +134,16 @@ export const make = <Key, E, Reason = never>(options: {
start(key, false, scope)
})
const interrupt = (key: Key, reason?: Reason): Effect.Effect<boolean> =>
Effect.sync(() => {
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined || execution.stopping) return false
if (execution === undefined || execution.stopping) return Effect.void
if (execution.owner === undefined) {
// Settlement window: the owner exited but the settled hook has not finished. The
// terminal outcome is already decided, so no reason attaches — but the interrupt
// still claims the recorded wakes so settle does not start a dead-intent successor.
execution.pendingWake = undefined
return false
return Effect.void
}
execution.stopping = true
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
@@ -154,7 +153,7 @@ export const make = <Key, E, Reason = never>(options: {
// Fire and forget: nobody benefits from waiting out cleanup here, and callers like
// the interrupt endpoint must acknowledge immediately even when finalizers are slow.
fork(Fiber.interrupt(execution.owner))
return true
return Effect.void
})
// One execution's `done` already spans coalesced continuations; re-check after it
+28 -77
View File
@@ -1,20 +1,7 @@
import type { ToolDefinition } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
const jsonSchemas = Effect.runSync(
Cache.make<JsonSchema.JsonSchema, Schema.Codec<unknown> | undefined>({
capacity: 100,
lookup: (schema) =>
Effect.try({
try: () => jsonSchema(schema),
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined)),
}),
)
import { Effect, JsonSchema, Schema } from "effect"
export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
name: effectiveName(tool),
@@ -25,7 +12,7 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool, input)
const decoded = yield* decodeInput(tool.input, input)
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
// downstream and leave its call permanently unsettled, so the declared contract is
@@ -57,51 +44,13 @@ export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool
}
})
const decodeInput = (tool: Tool.Info<any, any>, value: unknown) =>
Effect.gen(function* () {
const result = yield* validateInput(tool.input, value)
if (result.issues)
return yield* new Tool.Error({ message: formatInputIssues(effectiveName(tool), result.issues, value) })
return result.value
})
const validateInput = (
schema: Tool.ValueSchema<any>,
value: unknown,
): Effect.Effect<StandardSchemaV1.Result<unknown>> => {
if (isStandardSchema(schema)) return validateStandard(schema, value)
return Effect.gen(function* () {
const codec = Schema.isSchema(schema) ? schema : yield* Cache.get(jsonSchemas, schema)
if (codec === undefined) return { value }
return yield* Schema.decodeUnknownEffect(codec)(value, { errors: "all" }).pipe(
Effect.match({
onFailure: (error) => formatEffectIssues(error.issue),
onSuccess: (value) => ({ value }),
}),
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
if (Schema.isSchema(schema))
return Schema.decodeUnknownEffect(schema)(value).pipe(
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
)
})
}
const formatInputIssues = (tool: string, issues: ReadonlyArray<StandardSchemaV1.Issue>, value: unknown) => {
const details = issues.slice(0, 5).map((issue) => {
const path =
issue.path?.reduce<string>((path, segment) => {
const key = typeof segment === "object" ? segment.key : segment
if (typeof key === "number") return `${path}[${key}]`
return path === "" ? String(key) : `${path}.${String(key)}`
}, "") || "root"
return `- ${path}: ${issue.message}`
})
if (issues.length > 5) details.push(`- ...and ${issues.length - 5} more ${issues.length === 6 ? "issue" : "issues"}`)
return `Invalid arguments for tool "${tool}":\n${details.join("\n")}\n\nArguments provided:\n${JSON.stringify(value, null, 2)}\n\nUpdate the arguments and call the tool again.`
}
const jsonSchema = (schema: JsonSchema.JsonSchema) => {
const draft =
(typeof schema.$schema === "string" && schema.$schema.includes("draft-07")) || "definitions" in schema
? JsonSchema.fromSchemaDraft07(schema)
: JsonSchema.fromSchemaDraft2020_12(schema)
return Schema.make<Schema.Codec<unknown>>(SchemaRepresentation.fromJsonSchemaDocument(draft).ast)
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
return Effect.succeed(value)
}
const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
@@ -113,15 +62,7 @@ const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
),
)
if (isStandardSchema(schema))
return validateStandard(schema, value).pipe(
Effect.flatMap((result) =>
result.issues
? new Tool.Error({
message: `Tool returned an invalid value for its output schema: ${result.issues.map((issue) => issue.message).join(", ")}`,
})
: Effect.succeed(result.value),
),
)
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
return Schema.decodeUnknownEffect(Schema.Json)(value).pipe(
Effect.mapError(
(error) => new Tool.Error({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }),
@@ -137,16 +78,26 @@ const isStandardSchema = (
const validateStandard = (
schema: StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>,
value: unknown,
): Effect.Effect<StandardSchemaV1.Result<unknown>> =>
prefix: string,
) =>
Effect.gen(function* () {
const result = yield* Effect.try({ try: () => schema["~standard"].validate(value), catch: (error) => error })
return result instanceof Promise ? yield* Effect.tryPromise({ try: () => result, catch: (error) => error }) : result
}).pipe(
Effect.match({
onFailure: (error) => ({ issues: [{ message: error instanceof Error ? error.message : String(error) }] }),
onSuccess: (result) => result,
}),
)
const pending = yield* Effect.try({
try: () => schema["~standard"].validate(value),
catch: (error) => standardFailure(prefix, error),
})
const result =
pending instanceof Promise
? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) })
: pending
if (result.issues)
return yield* new Tool.Error({
message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}`,
})
return result.value
})
const standardFailure = (prefix: string, error: unknown) =>
new Tool.Error({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
const inputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
if (schema === undefined || schema === null) return {}
+14 -54
View File
@@ -25,18 +25,9 @@ export class Info extends Schema.Class<Info>("Workspace.Info")({
export class NotFound extends Schema.TaggedError<NotFound>()("Workspace.NotFound", { workspaceID: ID }) {}
export class CreateConflict extends Schema.TaggedError<CreateConflict>()("Workspace.CreateConflict", {
workspaceID: ID,
provider: Schema.String,
existingProvider: Schema.String,
}) {}
export interface Interface {
/** Instantly commits a logical workspace ID. No provider work happens here. */
readonly create: (input: {
readonly id?: ID
readonly provider: string
}) => Effect.Effect<ID, CreateConflict | WorkspaceDriver.ProviderNotFound>
readonly create: (provider: string) => Effect.Effect<ID, WorkspaceDriver.ProviderNotFound>
/** Starts or joins the shared attempt that makes the backing resource real, then returns it. */
readonly provision: (
workspaceID: ID,
@@ -44,11 +35,9 @@ export interface Interface {
readonly connect: (
workspaceID: ID,
) => Effect.Effect<EnvironmentDriver, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
/** Makes the workspace absent; reports whether this call destroyed an existing workspace. */
readonly destroy: (workspaceID: ID) => Effect.Effect<
Workspace.DestroyResult,
WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound
>
readonly destroy: (
workspaceID: ID,
) => Effect.Effect<void, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
}
export interface Options {
@@ -90,16 +79,13 @@ const layer = (options: Options) =>
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const idleThreshold = Duration.toMillis(options.idleThreshold ?? Duration.minutes(20))
const find = (workspaceID: ID) =>
db
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
const row = yield* db
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.get()
.pipe(Effect.orDie)
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
const row = yield* find(workspaceID)
if (!row) return yield* new NotFound({ workspaceID })
return row
})
@@ -221,39 +207,15 @@ const layer = (options: Options) =>
}).pipe(Effect.repeat(Schedule.spaced(options.pollInterval ?? Duration.minutes(1))), Effect.forkScoped)
return Service.of({
create: Effect.fn("Workspace.create")(function* (input) {
const workspaceID = input.id ?? ID.create()
const existing = yield* db
.select({ provider: WorkspaceTable.provider })
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.get()
.pipe(Effect.orDie)
if (existing) {
if (existing.provider === input.provider) return workspaceID
return yield* new CreateConflict({
workspaceID,
provider: input.provider,
existingProvider: existing.provider,
})
}
yield* registry.get(input.provider)
create: Effect.fn("Workspace.create")(function* (provider) {
yield* registry.get(provider)
const workspaceID = ID.create()
const now = yield* Clock.currentTimeMillis
const inserted = yield* db
yield* db
.insert(WorkspaceTable)
.values({ id: workspaceID, provider: input.provider, binding: null, created_at: now, last_used_at: now })
.onConflictDoNothing()
.returning({ id: WorkspaceTable.id })
.get()
.values({ id: workspaceID, provider, binding: null, created_at: now, last_used_at: now })
.run()
.pipe(Effect.orDie)
if (inserted) return workspaceID
const row = yield* load(workspaceID).pipe(Effect.orDie)
if (row.provider !== input.provider)
return yield* new CreateConflict({
workspaceID,
provider: input.provider,
existingProvider: row.provider,
})
return workspaceID
}),
provision,
@@ -305,10 +267,9 @@ const layer = (options: Options) =>
attempts.delete(workspaceID)
Deferred.doneUnsafe(attempt, Exit.fail(new NotFound({ workspaceID })))
}
return yield* locks.withLock(workspaceID)(
yield* locks.withLock(workspaceID)(
Effect.gen(function* () {
const row = yield* find(workspaceID)
if (!row) return { destroyed: false }
const row = yield* load(workspaceID)
const connection = connections.get(workspaceID)
connections.delete(workspaceID)
if (connection) yield* Scope.close(connection.scope, Exit.void)
@@ -323,7 +284,6 @@ const layer = (options: Options) =>
),
)
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
return { destroyed: true }
}),
)
}),
+60 -54
View File
@@ -1,71 +1,77 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Command } from "@opencode-ai/core/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Session } from "@opencode-ai/schema/session"
import { Effect } from "effect"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(Command.node))
const it = testEffect(
AppNodeBuilder.build(Command.node, [
[MCP.node, emptyMcpLayer],
[Location.node, testLocationLayer],
]),
)
describe("Command", () => {
it.effect("registers and executes callback commands", () =>
it.effect("applies command transforms and preserves later overrides", () =>
Effect.gen(function* () {
const command = yield* Command.Service
const calls: Command.Invocation[] = []
yield* command.transform((draft) => {
draft.add({
name: "goal",
description: "Manage the session goal",
execute: (input) => Effect.sync(() => calls.push(input)),
yield* command.transform((editor) => {
editor.update("review", (command) => {
command.template = "First"
command.description = "Review code"
})
editor.update("review", (command) => {
command.template = "Second"
command.model = {
id: Model.ID.make("claude"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("high"),
}
})
})
expect(yield* command.get("goal")).toEqual(
Command.Info.make({ name: "goal", description: "Manage the session goal" }),
)
const invocation = {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "ship it", files: [{ uri: "file:///tmp/plan.md" }] },
delivery: "steer" as const,
}
yield* command.execute({ name: "goal", invocation })
expect(calls).toEqual([invocation])
}),
)
it.effect("replaces commands with later definitions", () =>
Effect.gen(function* () {
const command = yield* Command.Service
yield* command.transform((draft) => {
draft.add({ name: "goal", description: "First", execute: () => Effect.void })
draft.add({ name: "goal", description: "Second", execute: () => Effect.void })
})
expect(yield* command.list()).toEqual([Command.Info.make({ name: "goal", description: "Second" })])
}),
)
it.effect("returns callback error messages without stack traces", () =>
Effect.gen(function* () {
const command = yield* Command.Service
yield* command.transform((draft) => {
draft.add({
name: "fail",
execute: () => Effect.fail(new Error("command failed")),
})
})
const error = yield* command
.execute({
name: "fail",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "" },
delivery: "steer",
expect(yield* command.get("review")).toEqual(
Command.Info.make({
name: "review",
template: "Second",
description: "Review code",
model: {
id: Model.ID.make("claude"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("high"),
},
}),
)
expect(yield* command.list()).toEqual([
Command.Info.make({
name: "review",
template: "Second",
description: "Review code",
model: {
id: Model.ID.make("claude"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("high"),
},
}),
])
}),
)
it.effect("evaluates command template shell blocks", () =>
Effect.gen(function* () {
const command = yield* Command.Service
yield* command.transform((editor) => {
editor.update("review", (command) => {
command.template = "Output: !`echo command-output`"
})
.pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "Command.ExecutionError", message: "command failed" })
})
expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output")
}),
)
})
+47 -126
View File
@@ -1,13 +1,11 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { advance, drain } from "../lib/clock"
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Command } from "@opencode-ai/core/command"
import { Agent } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -17,11 +15,11 @@ import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
@@ -30,25 +28,12 @@ import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const shellLayer = Layer.succeed(
ShellSelect.Service,
ShellSelect.Service.of({
preferred: () => Effect.succeed("sh"),
transform: () => Effect.die("unused shell.transform"),
reload: () => Effect.die("unused shell.reload"),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
[
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
[ShellSelect.node, shellLayer],
],
),
AppNodeBuilder.build(LayerNode.group([Command.node, Bus.node, FSUtil.node]), [
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
]),
)
const decode = Schema.decodeUnknownSync(Info)
@@ -80,7 +65,6 @@ Review files`,
const bus = yield* Bus.Service
const update = yield* bus.publish(Event.Updated, {})
const updates = yield* PubSub.unbounded<typeof update>()
const prompts: { text: string; files?: readonly { readonly uri: string }[]; delivery?: string }[] = []
yield* ConfigCommandPlugin.Plugin.effect(
host({
command: {
@@ -89,20 +73,6 @@ Review files`,
reload: command.reload,
},
event: { subscribe: () => Stream.fromPubSub(updates) },
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "user",
payload: { text: input.text },
delivery: input.delivery ?? "steer",
})
}),
},
}),
).pipe(
Effect.provide(
@@ -119,46 +89,28 @@ Review files`,
expect(yield* command.list()).toEqual([
Command.Info.make({
name: "review",
template: "Review files",
description: "File review",
agent: Agent.ID.make("reviewer"),
model: {
providerID: Provider.ID.make("anthropic"),
id: Model.ID.make("claude"),
variant: Model.VariantID.make("high"),
},
subtask: true,
}),
Command.Info.make({ name: "empty" }),
Command.Info.make({ name: "nested/docs" }),
])
yield* command.execute({
name: "nested/docs",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "details", files: [{ uri: "file:///tmp/context.md" }] },
delivery: "queue",
},
})
expect(prompts).toEqual([
{
text: "Write docs\n\ndetails",
files: [{ uri: "file:///tmp/context.md" }],
delivery: "queue",
},
Command.Info.make({ name: "empty", template: "" }),
Command.Info.make({ name: "nested/docs", template: "Write docs" }),
])
yield* Effect.promise(() =>
fs.writeFile(path.join(tmp.path, "commands", "review.md"), markdown("Review again", "Review again")),
)
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, update)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* command.get("review"))?.description === "Review again") break
if ((yield* command.get("review"))?.template === "Review again") break
yield* Effect.sleep("10 millis")
}
expect((yield* command.get("review"))?.description).toBe("Review again")
yield* command.execute({
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "latest" },
delivery: "steer",
},
})
expect(prompts.at(-1)?.text).toBe("Review again\n\nlatest")
expect((yield* command.get("review"))?.template).toBe("Review again")
}),
),
),
@@ -241,13 +193,11 @@ Review files`,
yield* advance(() => reloads >= 1)
expect(reloads).toBe(1)
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review twice", "Review twice")),
)
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review twice"))
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
yield* advance(() => reloads >= 2)
expect(reloads).toBe(2)
expect((yield* command.get("review"))?.description).toBe("Review twice")
expect((yield* command.get("review"))?.template).toBe("Review twice")
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
),
),
@@ -282,12 +232,10 @@ Review files`,
expect(reloads).toBe(0)
// The feed stays live after unrelated updates.
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review related", "Review related")),
)
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review related"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
yield* advance(() => reloads >= 1)
expect((yield* command.get("review"))?.description).toBe("Review related")
expect((yield* command.get("review"))?.template).toBe("Review related")
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
),
),
@@ -324,47 +272,28 @@ describeNative("ConfigCommandPlugin native watcher", () => {
yield* watchReady(config, global)
const created = yield* nextCommandUpdate(bus)
yield* fs.writeFileString(
path.join(global, "commands", "review.md"),
markdown("Review native", "Review native"),
)
yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native")
yield* Fiber.join(created).pipe(Effect.timeout("10 seconds"))
expect((yield* command.get("review"))?.description).toBe("Review native")
expect((yield* command.get("review"))?.template).toBe("Review native")
const updated = yield* nextCommandUpdate(bus)
yield* fs.writeFileString(
path.join(global, "commands", "review.md"),
markdown("Review native again", "Review native again"),
)
yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native again")
yield* Fiber.join(updated).pipe(Effect.timeout("10 seconds"))
expect((yield* command.get("review"))?.description).toBe("Review native again")
expect((yield* command.get("review"))?.template).toBe("Review native again")
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
Command.node,
Config.node,
Bus.node,
FSUtil.node,
AppProcess.node,
Global.node,
Location.node,
ShellSelect.node,
]),
AppNodeBuilder.build(LayerNode.group([Command.node, Config.node, Bus.node, FSUtil.node]), [
[
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[ShellSelect.node, shellLayer],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
),
],
),
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
]),
),
)
}),
@@ -408,10 +337,6 @@ function directoryEntry(directory: string) {
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
}
function markdown(description: string, template: string) {
return `---\ndescription: ${description}\n---\n${template}`
}
function sourceCases() {
return [
{
@@ -420,37 +345,33 @@ function sourceCases() {
mutate: (directory: string) =>
Effect.promise(async () => {
const file = path.join(directory, "review.md")
await fs.writeFile(file, markdown("Review created", "Review created"))
await fs.writeFile(file, "Review created")
return [{ type: "create" as const, path: file }]
}),
verify: (command: Command.Interface) =>
Effect.gen(function* () {
expect((yield* command.get("review"))?.description).toBe("Review created")
expect((yield* command.get("review"))?.template).toBe("Review created")
}),
},
{
name: "updated",
prepare: (directory: string) =>
Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first")),
),
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review first")),
mutate: (directory: string) =>
Effect.promise(async () => {
const file = path.join(directory, "review.md")
await fs.writeFile(file, markdown("Review updated", "Review updated"))
await fs.writeFile(file, "Review updated")
return [{ type: "update" as const, path: file }]
}),
verify: (command: Command.Interface) =>
Effect.gen(function* () {
expect((yield* command.get("review"))?.description).toBe("Review updated")
expect((yield* command.get("review"))?.template).toBe("Review updated")
}),
},
{
name: "renamed",
prepare: (directory: string) =>
Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review renamed", "Review renamed")),
),
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review renamed")),
mutate: (directory: string) =>
Effect.promise(async () => {
const previous = path.join(directory, "review.md")
@@ -464,7 +385,7 @@ function sourceCases() {
verify: (command: Command.Interface) =>
Effect.gen(function* () {
expect(yield* command.get("review")).toBeUndefined()
expect((yield* command.get("release"))?.description).toBe("Review renamed")
expect((yield* command.get("release"))?.template).toBe("Review renamed")
}),
},
{
-11
View File
@@ -52,17 +52,6 @@ describe("PluginSupervisor config", () => {
),
)
it.live("allows the built-in Plan agent to be disabled", () =>
withLocation(
{ agents: { plan: { disabled: true } } },
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("plan"))).toBeUndefined()
}),
),
)
it.live("loads configured Promise plugins with options", () =>
withLocation(
{
+2 -8
View File
@@ -14,22 +14,16 @@ import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Provider } from "@opencode-ai/core/provider"
import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Schema } from "effect"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
)
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
File diff suppressed because one or more lines are too long
+2 -43
View File
@@ -1,18 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Command } from "@opencode-ai/core/command"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime } from "effect"
import { emptyMcpLayer } from "../fixture/mcp"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "./host"
@@ -23,18 +15,12 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory }, { projectDirectory: project })),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Command.node, MCP.node, Bus.node]), [
[MCP.node, emptyMcpLayer],
[Location.node, locationLayer],
]),
)
const it = testEffect(AppNodeBuilder.build(Command.node, [[Location.node, locationLayer]]))
describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
Effect.gen(function* () {
const command = yield* Command.Service
const prompts: { text: string; files?: readonly { readonly uri: string }[] }[] = []
yield* CommandPlugin.Plugin.effect(
host({
command: {
@@ -42,20 +28,6 @@ describe("CommandPlugin.Plugin", () => {
transform: command.transform,
reload: command.reload,
},
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "user",
payload: { text: input.text },
delivery: input.delivery ?? "steer",
})
}),
},
}),
).pipe(
Effect.provideService(
@@ -68,24 +40,11 @@ describe("CommandPlugin.Plugin", () => {
name: "init",
description: "guided AGENTS.md setup",
})
expect((yield* command.get("init"))?.template).toContain("`/repo`")
expect(yield* command.get("review")).toMatchObject({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
})
yield* command.execute({
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "extra context", files: [{ uri: "file:///tmp/context.md" }] },
delivery: "queue",
},
})
expect(prompts).toEqual([
{
text: expect.stringContaining("extra context"),
files: [{ uri: "file:///tmp/context.md" }],
},
])
}),
)
})
+3 -5
View File
@@ -121,7 +121,7 @@ describe("fromPromise", () => {
}),
)
it.effect("preserves interrupt results and rejected Promise behavior", () =>
it.effect("preserves no-content and rejected Promise behavior", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const host = testHost({
@@ -131,7 +131,7 @@ describe("fromPromise", () => {
return Effect.fail(new Error("interrupt failed"))
}
expect(input.continue).toBe(true)
return Effect.succeed({ interrupted: false })
return Effect.void
},
switchAgent: (input) => Effect.sync(() => seen.push(input)),
switchModel: (input) => Effect.sync(() => seen.push(input)),
@@ -144,9 +144,7 @@ describe("fromPromise", () => {
define({
id: "promise-session-interrupt",
setup: async (ctx) => {
expect(await ctx.session.interrupt({ sessionID: "ses_success", continue: true })).toEqual({
interrupted: false,
})
expect(await ctx.session.interrupt({ sessionID: "ses_success", continue: true })).toBeUndefined()
await expect(ctx.session.interrupt({ sessionID: "ses_failure" })).rejects.toThrow("interrupt failed")
expect(await ctx.session.switchAgent({ sessionID: "ses_success", agent: "build" })).toBeUndefined()
expect(
+1 -14
View File
@@ -128,25 +128,12 @@ describe("SessionExecution lifecycle", () => {
yield* Deferred.await(draining)
expect((yield* claims(database))[sessionID]).toBe(true)
expect(yield* execution.interrupt(sessionID)).toBeTrue()
yield* execution.interrupt(sessionID)
yield* execution.awaitIdle(sessionID)
expect((yield* claims(database))[sessionID]).toBe(false)
}),
)
it.effect("reports an idle interrupt as a no-op", () =>
Effect.gen(function* () {
const sessionID = Session.ID.make("ses_idle_cancel")
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () => Effect.never)
const execution = Context.get(context, SessionExecution.Service)
expect(yield* execution.interrupt(sessionID)).toBeFalse()
expect(yield* execution.active).not.toContain(sessionID)
}),
)
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
+1 -2
View File
@@ -48,7 +48,6 @@ const execution = Layer.succeed(
Effect.sync(() => {
interruptCalls.push(sessionID)
interruptContinuations.push(options?.continue)
return activeSessions.delete(sessionID)
}),
wake: (sessionID) =>
Effect.sync(() => {
@@ -194,7 +193,7 @@ describe("Session.prompt", () => {
interruptCalls.length = 0
wakeCalls.length = 0
expect(yield* session.interrupt(sessionID)).toBeFalse()
yield* session.interrupt(sessionID)
expect(interruptCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([])
expect(yield* session.messages({ sessionID })).toEqual([])
@@ -236,7 +236,7 @@ describe("SessionRunCoordinator", () => {
drain: () => Effect.void,
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
})
expect(yield* coordinator.interrupt("session", "user")).toBeFalse()
yield* coordinator.interrupt("session", "user")
yield* coordinator.run("session")
expect(reasons).toEqual([undefined])
}),
@@ -260,7 +260,7 @@ describe("SessionRunCoordinator", () => {
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(settling)
expect(yield* coordinator.interrupt("session", "user")).toBeFalse()
yield* coordinator.interrupt("session", "user")
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(run)
yield* coordinator.run("session")
@@ -315,7 +315,7 @@ describe("SessionRunCoordinator", () => {
const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session")
expect(yield* coordinator.interrupt("session", "user")).toBeTrue()
yield* coordinator.interrupt("session", "user")
yield* Deferred.await(interrupted)
const exits = yield* Fiber.awaitAll([first, second, idle])
@@ -511,11 +511,7 @@ describe("Tool", () => {
}),
).toMatchObject({
status: "error",
error: {
type: "tool.execution",
message:
'Invalid arguments for tool "transformed":\n- value: Expected boolean\n\nArguments provided:\n{\n "value": "yes"\n}\n\nUpdate the arguments and call the tool again.',
},
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
})
expect(executed).toEqual(["yes"])
+1 -5
View File
@@ -100,11 +100,7 @@ describe("QuestionTool", () => {
}),
).toMatchObject({
status: "error",
error: {
type: "tool.execution",
message:
'Invalid arguments for tool "question":\n- questions: Expected a value with a length of at least 1\n\nArguments provided:\n{\n "questions": []\n}\n\nUpdate the arguments and call the tool again.',
},
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
})
expect(capturedInput()).toBeUndefined()
}),
+17 -160
View File
@@ -144,12 +144,7 @@ test("portable schema failures become tool failures", async () => {
"~standard": {
version: 1,
vendor: "test",
validate: (_value: unknown) => ({
issues: [
{ path: ["value"], message: "expected a string" },
{ path: [{ key: "nested" }, { key: "count" }], message: "expected a positive integer" },
],
}),
validate: (_value: unknown) => ({ issues: [{ message: "expected a string" }] }),
jsonSchema: {
input: () => ({ type: "string" }),
output: () => ({ type: "string" }),
@@ -157,76 +152,19 @@ test("portable schema failures become tool failures", async () => {
},
}
const error = await Effect.runPromise(
Effect.flip(
execute(
{
name: "invalid",
description: "Invalid",
input,
execute: () => Effect.succeed({ content: "unused" }),
},
1,
{} as Tool.Context,
),
const error = await Effect.runPromiseExit(
execute(
{
name: "invalid",
description: "Invalid",
input,
execute: () => Effect.succeed({ content: "unused" }),
},
1,
{} as Tool.Context,
),
)
expect(error).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "invalid":\n- value: expected a string\n- nested.count: expected a positive integer\n\nArguments provided:\n1\n\nUpdate the arguments and call the tool again.',
}),
)
})
test("Effect schema failures use normalized input issues", async () => {
const tool: Info = {
name: "effect",
description: "Effect tool",
input: Schema.Struct({
value: Schema.String,
nested: Schema.Struct({ count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)) }),
}),
execute: () => Effect.succeed({ content: "unused" }),
}
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "effect":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
}),
)
})
test("input error prompts limit normalized issues", async () => {
const input = {
"~standard": {
version: 1,
vendor: "test",
validate: (_value: unknown) => ({
issues: Array.from({ length: 6 }, (_, index) => ({ message: `issue ${index + 1}` })),
}),
jsonSchema: {
input: () => ({}),
output: () => ({}),
},
},
}
const tool: Info = {
name: "limited",
description: "Limited issues",
input,
execute: () => Effect.succeed({ content: "unused" }),
}
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "limited":\n- root: issue 1\n- root: issue 2\n- root: issue 3\n- root: issue 4\n- root: issue 5\n- ...and 1 more issue\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(error.toString()).toContain("Invalid tool input: expected a string")
})
test("canonical results carry metadata with typed output", async () => {
@@ -247,21 +185,8 @@ test("canonical results carry metadata with typed output", async () => {
})
})
test("raw JSON schemas validate and decode tool input", async () => {
const input = {
type: "object",
properties: {
value: { type: "string" },
nested: {
type: "object",
properties: { count: { type: "integer", minimum: 1 } },
required: ["count"],
additionalProperties: false,
},
},
required: ["value"],
additionalProperties: false,
}
test("raw JSON schemas are render-only and omitted output means model-only", async () => {
const input = { type: "object", properties: { value: { type: "string" } } }
const tool: Info = {
name: "raw",
description: "Raw tool",
@@ -272,79 +197,11 @@ test("raw JSON schemas validate and decode tool input", async () => {
expect(definition(tool)).toEqual({
name: "raw",
description: "Raw tool",
inputSchema: input,
inputSchema: { type: "object", properties: { value: { type: "string" } } },
})
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, {} as Tool.Context))).toEqual({
expect(await Effect.runPromise(execute(tool, { value: 1 }, {} as Tool.Context))).toEqual({
output: undefined,
content: [{ type: "text", text: '{"value":"ok"}' }],
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Expected string\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Missing key\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": "ok",\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
}),
)
})
test("raw JSON schemas resolve draft-07 definitions", async () => {
const tool: Info = {
name: "draft-07",
description: "Draft-07 tool",
input: {
type: "object",
properties: { value: { $ref: "#/definitions/value" } },
required: ["value"],
definitions: { value: { type: "string" } },
},
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
}
expect(await Effect.runPromise(execute(tool, { value: "ok" }, {} as Tool.Context))).toMatchObject({
content: [{ type: "text", text: '{"value":"ok"}' }],
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "draft-07":\n- value: Expected value\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
}),
)
})
test("raw JSON schemas pass input through when they cannot be imported", async () => {
const tool: Info = {
name: "invalid-schema",
description: "Invalid schema tool",
input: {
type: "object",
properties: { value: { $ref: "#/$defs/missing" } },
},
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
}
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, {} as Tool.Context))).toMatchObject({
content: [{ type: "text", text: '{"value":1,"extra":true}' }],
content: [{ type: "text", text: '{"value":1}' }],
})
})
+1 -2
View File
@@ -118,8 +118,7 @@ describe("search tools", () => {
status: "error",
error: {
type: "tool.execution",
message:
'Invalid arguments for tool "grep":\n- pattern: Pattern must not be empty\n\nArguments provided:\n{\n "pattern": ""\n}\n\nUpdate the arguments and call the tool again.',
message: 'Invalid tool input: Pattern must not be empty\n at ["pattern"]',
},
})
}),
+1 -1
View File
@@ -115,7 +115,7 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()),
resume: complete,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
interrupt: () => Effect.void,
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
})
}),
+1 -1
View File
@@ -88,7 +88,7 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()),
resume: complete,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
interrupt: () => Effect.void,
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
})
}),
+12 -97
View File
@@ -41,7 +41,7 @@ const driver = WorkspaceDriver.make({
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Workspace.configured({ idleThreshold: "5 minutes", pollInterval: "1 minute" })]),
[[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: driver, other: driver })]],
[[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: driver })]],
),
)
@@ -77,7 +77,7 @@ it.effect("rejects unregistered workspace providers", () =>
it.effect("creates and persists an ID without provisioning", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
expect(workspaceID.startsWith("wrk_")).toBe(true)
expect(calls).toEqual([])
@@ -89,76 +89,12 @@ it.effect("creates and persists an ID without provisioning", () =>
}),
)
it.effect("creates a workspace with a caller-supplied ID", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const id = Workspace.ID.create()
expect(yield* workspace.create({ id, provider: "fake" })).toBe(id)
expect(
yield* Database.Service.use(({ db }) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get(),
).pipe(Effect.orDie),
).toMatchObject({ id, provider: "fake", binding: null })
}),
)
it.effect("reuses a caller-supplied ID with the same provider", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const id = Workspace.ID.create()
expect(yield* workspace.create({ id, provider: "fake" })).toBe(id)
expect(yield* workspace.create({ id, provider: "fake" })).toBe(id)
expect(
yield* Database.Service.use(({ db }) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).all(),
).pipe(Effect.orDie),
).toHaveLength(1)
expect(calls).toEqual([])
}),
)
it.effect("rejects a caller-supplied ID already assigned to another provider", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const id = Workspace.ID.create()
yield* workspace.create({ id, provider: "fake" })
expect(yield* workspace.create({ id, provider: "other" }).pipe(Effect.flip)).toEqual(
new Workspace.CreateConflict({ workspaceID: id, provider: "other", existingProvider: "fake" }),
)
}),
)
it.effect("resolves an existing caller-supplied ID before provider lookup", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const id = Workspace.ID.create()
yield* Database.Service.use(({ db }) =>
db
.insert(WorkspaceTable)
.values({ id, provider: "missing", binding: null, created_at: 0, last_used_at: 0 })
.run(),
).pipe(Effect.orDie)
expect(yield* workspace.create({ id, provider: "missing" })).toBe(id)
expect(yield* workspace.create({ id, provider: "another-missing" }).pipe(Effect.flip)).toEqual(
new Workspace.CreateConflict({
workspaceID: id,
provider: "another-missing",
existingProvider: "missing",
}),
)
}),
)
it.effect("destroys an unprovisioned workspace through the driver with a null binding", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: true })
yield* workspace.destroy(workspaceID)
expect(calls).toEqual([{ operation: "destroy", binding: null }])
expect(
yield* Database.Service.use(({ db }) =>
@@ -168,31 +104,10 @@ it.effect("destroys an unprovisioned workspace through the driver with a null bi
}),
)
it.effect("succeeds without calling the driver when the workspace does not exist", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = Workspace.ID.create()
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: false })
expect(calls).toEqual([])
}),
)
it.effect("reports whether destroy removed an existing workspace", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: true })
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: false })
expect(calls).toEqual([{ operation: "destroy", binding: null }])
}),
)
it.effect("starts eager provisioning in the background and lets callers join it", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const eager = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
@@ -211,7 +126,7 @@ it.effect("starts eager provisioning in the background and lets callers join it"
it.effect("starts lazy provisioning on the first spawn", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const environment = yield* workspace.connect(workspaceID)
const gate = yield* gateCreate()
@@ -230,7 +145,7 @@ it.effect("starts lazy provisioning on the first spawn", () =>
it.effect("shares provisioning between concurrent first spawns", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const environment = yield* workspace.connect(workspaceID)
const gate = yield* gateCreate()
@@ -254,7 +169,7 @@ it.effect("shares provisioning between concurrent first spawns", () =>
it.effect("keeps shared provisioning alive when a waiter is interrupted", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const owner = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
@@ -272,7 +187,7 @@ it.effect("keeps shared provisioning alive when a waiter is interrupted", () =>
it.effect("interrupts in-flight provisioning on destroy and fails waiters with NotFound", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
@@ -293,7 +208,7 @@ it.effect("interrupts in-flight provisioning on destroy and fails waiters with N
it.effect("shares a failed attempt and retries the same workspace ID", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let fail = true
@@ -327,7 +242,7 @@ it.effect("shares a failed attempt and retries the same workspace ID", () =>
it.effect("persists the workspace lifecycle and reconnects after idle suspension", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const created = yield* workspace.provision(workspaceID)
expect(created.id).toBe(workspaceID)
@@ -362,7 +277,7 @@ it.effect("persists the workspace lifecycle and reconnects after idle suspension
it.effect("surfaces wake failures through the spawn error channel", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const created = yield* workspace.provision(yield* workspace.create({ provider: "fake" }))
const created = yield* workspace.provision(yield* workspace.create("fake"))
const environment = yield* workspace.connect(created.id)
yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("connect"))).pipe(Effect.exit)
-3
View File
@@ -5,9 +5,6 @@
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
+26 -26
View File
@@ -5,7 +5,6 @@ import { jwtVerify, createRemoteJWKSet } from "jose"
import { createAppAuth } from "@octokit/auth-app"
import { Octokit } from "@octokit/rest"
import { Resource } from "sst"
import { parseRepositoryClaim } from "./github"
type Env = {
SYNC_SERVER: DurableObjectNamespace<SyncServer>
@@ -270,41 +269,42 @@ export default new Hono<{ Bindings: Env }>()
// verify token
const JWKS = createRemoteJWKSet(new URL(JWKS_URL))
let repository: ReturnType<typeof parseRepositoryClaim>
let owner, repo
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: GITHUB_ISSUER,
audience: EXPECTED_AUDIENCE,
})
repository = parseRepositoryClaim(payload)
const sub = payload.sub // e.g. 'repo:my-org/my-repo:ref:refs/heads/main'
const parts = sub.split(":")[1].split("/")
owner = parts[0]
repo = parts[1]
} catch (err) {
console.error("Token verification failed:", err)
return c.json({ error: "Invalid or expired token" }, { status: 403 })
}
try {
const auth = createAppAuth({
appId: Resource.GITHUB_APP_ID.value,
privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value,
})
const appAuth = await auth({ type: "app" })
const octokit = new Octokit({ auth: appAuth.token })
const { data: installation } = await octokit.apps.getRepoInstallation({
owner: repository.owner,
repo: repository.repo,
})
const installationAuth = await auth({
type: "installation",
installationId: installation.id,
})
return c.json({ token: installationAuth.token })
} catch (error) {
console.error("GitHub App token exchange failed:", error)
return c.json(
{ error: `Failed to exchange GitHub App token for ${repository.owner}/${repository.repo}` },
{ status: 502 },
)
}
// Create app JWT token
const auth = createAppAuth({
appId: Resource.GITHUB_APP_ID.value,
privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value,
})
const appAuth = await auth({ type: "app" })
// Lookup installation
const octokit = new Octokit({ auth: appAuth.token })
const { data: installation } = await octokit.apps.getRepoInstallation({
owner,
repo,
})
// Get installation token
const installationAuth = await auth({
type: "installation",
installationId: installation.id,
})
return c.json({ token: installationAuth.token })
})
/**
* Used by the GitHub action to get GitHub installation access token given user PAT token (used when testing `opencode github run` locally)
-14
View File
@@ -1,14 +0,0 @@
import type { JWTPayload } from "jose"
export function parseRepositoryClaim(payload: JWTPayload) {
const claim = payload.repository
if (typeof claim !== "string") throw new Error("Repository claim is missing")
const parts = claim.split("/")
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error("Repository claim is invalid")
return {
owner: parts[0],
repo: parts[1],
}
}
-39
View File
@@ -1,39 +0,0 @@
import { describe, expect, test } from "bun:test"
import { parseRepositoryClaim } from "../src/github"
describe("parseRepositoryClaim", () => {
test("reads repository identity with a legacy subject", () => {
expect(
parseRepositoryClaim({
repository: "octocat/my-repo",
sub: "repo:octocat/my-repo:ref:refs/heads/main",
}),
).toEqual({ owner: "octocat", repo: "my-repo" })
})
test("reads repository identity with an immutable subject", () => {
expect(
parseRepositoryClaim({
repository: "octocat/my-repo",
sub: "repo:octocat@123456/my-repo@456789:ref:refs/heads/main",
}),
).toEqual({ owner: "octocat", repo: "my-repo" })
})
test("does not depend on a repository path in a customized subject", () => {
expect(
parseRepositoryClaim({
repository: "octocat/my-repo",
sub: "repository_owner:octocat:repository_visibility:private",
}),
).toEqual({ owner: "octocat", repo: "my-repo" })
})
test("rejects a missing repository claim", () => {
expect(() => parseRepositoryClaim({})).toThrow("Repository claim is missing")
})
test("rejects an invalid repository claim", () => {
expect(() => parseRepositoryClaim({ repository: "octocat" })).toThrow("Repository claim is invalid")
})
})
-1
View File
@@ -10,7 +10,6 @@
"./plugin": "./src/plugin.ts"
},
"scripts": {
"audit:layouts": "bun run script/layout-audit.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
},
-98
View File
@@ -1,98 +0,0 @@
import { mkdir } from "node:fs/promises"
import { dirname, resolve } from "node:path"
import {
auditAllFixtures,
summarizeAudits,
worstAudits,
type LayoutAudit,
type LayoutMetrics,
} from "../src/test/layout-audit/harness.js"
const outputPath = resolve(import.meta.dir, "../../../tmp/merman-layout-audit.md")
const startedAt = performance.now()
const audits = auditAllFixtures()
const elapsedMs = performance.now() - startedAt
const summary = summarizeAudits(audits)
function label(audit: LayoutAudit): string {
return `${audit.fixture.id} @${audit.viewport}`
}
function metricTable(items: readonly LayoutAudit[]): string {
return [
"| Fixture | Viewport | Size | Area | Route length | Bends | Crossings | Shared cells | Overflow |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
...items.map(
(audit) =>
`| \`${audit.fixture.id}\` | ${audit.viewport} | ${audit.metrics.width}x${audit.metrics.height} | ${audit.metrics.area} | ${audit.metrics.routeLength} | ${audit.metrics.bends} | ${audit.metrics.crossings} | ${audit.metrics.sharedRouteCells} | ${audit.metrics.overflow} |`,
),
].join("\n")
}
function worstSection(metric: keyof LayoutMetrics): string {
const worst = worstAudits(audits, metric)
return [`### ${metric}`, "", metricTable(worst)].join("\n")
}
function fixtureSection(audit: LayoutAudit): string {
return [
`<details${audit.fixture.curated || audit.violations.length > 0 ? " open" : ""}>`,
`<summary><code>${label(audit)}</code> · ${audit.metrics.width}x${audit.metrics.height} · area ${audit.metrics.area} · bends ${audit.metrics.bends} · crossings ${audit.metrics.crossings} · overflow ${audit.metrics.overflow}</summary>`,
"",
...(audit.violations.length > 0 ? ["Violations:", "", ...audit.violations.map((item) => `- ${item}`), ""] : []),
"Source:",
"",
"```mermaid",
audit.fixture.source,
"```",
"",
"Rendered output:",
"",
"```text",
audit.output,
"```",
"",
"</details>",
].join("\n")
}
const grouped = Map.groupBy(audits, (audit) => `${audit.fixture.kind}/${audit.fixture.family}`)
const violations = audits.flatMap((audit) => audit.violations.map((violation) => `${label(audit)}: ${violation}`))
const markdown = [
"# Merman Layout Audit",
"",
`Generated from ${new Set(audits.map((audit) => audit.fixture.id)).size} sources and ${audits.length} layout runs.`,
"",
`Structural violations: **${violations.length}**`,
"",
"## Aggregate Metrics",
"",
"```json",
JSON.stringify(summary, null, 2),
"```",
"",
"## Worst Offenders",
"",
...(["area", "bends", "crossings", "sharedRouteCells", "overflow"] as const).flatMap((metric) => [
worstSection(metric),
"",
]),
"## Fixtures",
"",
...[...grouped.entries()].flatMap(([family, items]) => [
`### ${family}`,
"",
metricTable(items),
"",
...items.flatMap((audit) => [fixtureSection(audit), ""]),
]),
].join("\n")
await mkdir(dirname(outputPath), { recursive: true })
await Bun.write(outputPath, markdown)
console.log(`Wrote ${audits.length} layout runs to ${outputPath} in ${elapsedMs.toFixed(0)}ms`)
if (violations.length > 0) {
console.error(violations.join("\n"))
process.exitCode = 1
}
+7 -9
View File
@@ -43,9 +43,8 @@ function mergeFlowchartCell(
if (incoming.style !== "edge") return incoming
if (existing.style === "label") return existing
if (incoming.char === " ") return existing
if (existing.style !== "edge" || existing.char === " ") return incoming
if (DIAGRAM_ARROW_HEADS.has(existing.char)) return existing
if (DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
if ((existing.style !== "edge" && existing.style !== "group") || existing.char === " ") return incoming
if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
return {
...incoming,
@@ -76,23 +75,22 @@ function drawNode(
): void {
const chars = BorderChars[borderStyle]
const style: FlowchartCellStyle = node.shape === "database" ? "database" : "node"
const border: FlowchartCellStyle = node.shape === "database" ? "databaseBorder" : "nodeBorder"
if (node.shape === "decision") {
drawDiagramDiamond(
bounds,
(x, y, char) => grid.setCell(x, y, char, border),
(x, y, char) => grid.setCell(x, y, char, style),
diagramDiamondCharactersFromBorder(chars),
)
} else if (node.shape === "subroutine") {
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
drawSubroutineNode(grid, bounds, chars, border)
drawSubroutineNode(grid, bounds, chars, style)
} else if (node.shape === "database") {
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
drawDatabaseNode(grid, bounds, chars, border)
drawDatabaseNode(grid, bounds, chars, style)
} else {
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, border))
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, style))
}
const textTop =
@@ -272,7 +270,7 @@ function drawSourceConnectors(
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
if (routeDirection && connectorDirection) {
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
if (cell && cell.style !== "label" && !DIAGRAM_ARROW_HEADS.has(cell.char)) {
if (cell && cell.style !== "label") {
grid.replaceCell(
sourcePoint.x,
sourcePoint.y,
+21 -367
View File
@@ -1,10 +1,7 @@
import { describe, expect, test } from "bun:test"
import { parseColor, TextAttributes } from "@opentui/core"
import stringWidth from "string-width"
import { diagramArrowHeadBetween } from "../core/drawing.js"
import { orthogonalPathPoints } from "../core/geometry.js"
import { expectDiagram } from "../test/diagram.js"
import { deploymentArchitectureSource } from "../test/layout-audit/fixtures.js"
import { drawFlowchartDiagramGrid as drawParsedFlowchartDiagramGrid } from "./drawing.js"
import {
DEFAULT_MIN_RANK_GAP,
@@ -38,7 +35,7 @@ function routeRunsAlongHorizontalBorder(
const from = route.points[index - 1]!
const to = route.points[index]!
if (from.y !== to.y || !borderYs.has(from.y)) continue
if (Math.min(Math.max(from.x, to.x), right) > Math.max(Math.min(from.x, to.x), left)) return true
if (Math.max(from.x, to.x) >= left && Math.min(from.x, to.x) <= right) return true
}
return false
}
@@ -55,7 +52,7 @@ function routeRunsAlongVerticalBorder(
const from = route.points[index - 1]!
const to = route.points[index]!
if (from.x !== to.x || !borderXs.has(from.x)) continue
if (Math.min(Math.max(from.y, to.y), bottom) > Math.max(Math.min(from.y, to.y), top)) return true
if (Math.max(from.y, to.y) >= top && Math.min(from.y, to.y) <= bottom) return true
}
return false
}
@@ -117,111 +114,6 @@ function boundsIntersect(
)
}
function boundsContains(
outer: { left: number; top: number; width: number; height: number },
inner: { left: number; top: number; width: number; height: number },
): boolean {
return (
inner.left >= outer.left &&
inner.top >= outer.top &&
inner.left + inner.width <= outer.left + outer.width &&
inner.top + inner.height <= outer.top + outer.height
)
}
function routesIntersect(
left: { points: readonly { x: number; y: number }[] },
right: { points: readonly { x: number; y: number }[] },
): boolean {
const occupied = new Set(orthogonalPathPoints(left.points).map((point) => `${point.x}:${point.y}`))
return orthogonalPathPoints(right.points).some((point) => occupied.has(`${point.x}:${point.y}`))
}
function renderedDimensions(output: string): { width: number; height: number } {
const lines = output.split("\n")
return { width: Math.max(...lines.map((line) => stringWidth(line))), height: lines.length }
}
function expectResponsiveFlowchartValid(content: string, layoutMaxWidth: number) {
const diagram = parseMermaidFlowchartDiagram(content)
const options = { compact: true, layoutMaxWidth }
const layout = layoutParsedFlowchartDiagram(diagram, options)
const grid = drawParsedFlowchartDiagramGrid(diagram, options)
const output = renderFlowchartDiagram(content, options)
const nodes = [...layout.bounds.values()]
expect(layout.diagram.direction).toBe("TD")
for (let left = 0; left < nodes.length; left++) {
for (let right = left + 1; right < nodes.length; right++) {
expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
}
}
for (const route of layout.routes) {
expect(route.points.length).toBeGreaterThanOrEqual(2)
for (let index = 1; index < route.points.length; index++) {
const from = route.points[index - 1]!
const to = route.points[index]!
expect(from.x === to.x || from.y === to.y).toBe(true)
}
expect(terminalPointsTowardBounds(route, layout.bounds.get(route.edge.to)!)).toBe(true)
const end = route.points.at(-1)!
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
if (route.edge.label) expect(output).toContain(route.edge.label)
}
expectFlowchartRoutesAvoidUnrelatedNodes(layout)
for (const subgraph of diagram.subgraphs ?? []) {
const frame = layout.subgraphBounds.get(subgraph.id)!
for (const nodeId of subgraph.nodeIds) expect(boundsContains(frame, layout.bounds.get(nodeId)!)).toBe(true)
expect(output).toContain(subgraph.label)
}
for (const node of layout.bounds.values()) {
for (const line of node.lines) expect(output).toContain(line)
}
const widestContent = Math.max(
...nodes.map((node) => node.width),
...layout.routes.flatMap((route) =>
route.edge.label ? [flowchartRouteLabelLayout(route, visualLength).width] : [],
),
)
const dimensions = renderedDimensions(output)
expect(Math.max(...output.split("\n").map((line) => stringWidth(line)))).toBeLessThanOrEqual(
layoutMaxWidth + widestContent + 4,
)
return { dimensions, layout, output }
}
function generatedWideRankFlowchart(count: number): string {
const labels = [
"地域 gateway Ω",
"界面 worker λ",
"Long-running synchronization service",
"Cache café 🚀",
"Audit and observability pipeline",
"Provider μ endpoint",
"Fallback Ж service",
"Archive 数据 lake",
"Terminal résumé queue",
]
const branches = labels
.slice(0, count)
.flatMap((label, index) => [
` Hub ${index === 0 ? "-->|dispatch across regions and providers|" : "-->"} N${index}[${label}]`,
` N${index} --> Join`,
])
return [
"flowchart LR",
" Start[Client α] --> Hub",
" subgraph Services [地域 services Ω]",
" Hub[Dispatch hub]",
...branches,
" Join[Join results]",
" end",
" Join --> Done[Complete ✓]",
].join("\n")
}
function expectFlowchartRoutesAvoidUnrelatedNodes(layout: ReturnType<typeof layoutFlowchartDiagram>): void {
for (const route of layout.routes) {
for (const [id, bounds] of layout.bounds) {
@@ -435,52 +327,6 @@ describe("FlowchartDiagram", () => {
`)
})
test.each(
(["LR", "RL", "TD", "TB", "BT"] as const).flatMap((direction) =>
[false, true].map((compact) => ({ direction, compact })),
),
)(
"preserves every target arrowhead after painting $direction routes with compact=$compact",
({ direction, compact }) => {
const content = `flowchart ${direction}
A[A]
B[B]
C[C]
D[D]
A --> A
A --> C
C --> B`
const diagram = parseMermaidFlowchartDiagram(content)
const layout = layoutParsedFlowchartDiagram(diagram, { compact })
const grid = drawParsedFlowchartDiagramGrid(diagram, { compact })
for (const route of layout.routes) {
const end = route.points.at(-1)!
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
}
},
)
test.each(
(["LR", "RL", "TD", "TB", "BT"] as const).flatMap((direction) =>
[false, true].map((compact) => ({ direction, compact })),
),
)("keeps crossed endpoint-disjoint $direction routes separate with compact=$compact", ({ direction, compact }) => {
const layout = layoutFlowchartDiagram(
`flowchart ${direction}
A[A]
B[B]
C[C]
D[D]
A --> C
D --> B`,
{ compact },
)
expect(layout.routes).toHaveLength(2)
expect(routesIntersect(layout.routes[0]!, layout.routes[1]!)).toBe(false)
})
test("keeps vertical feedback labels clear of unrelated nodes", () => {
const content = `flowchart TD
S[Source] --> A[Alpha]
@@ -767,7 +613,6 @@ describe("FlowchartDiagram", () => {
const loops = layout.routes.filter((route) => route.edge.from === "B" && route.edge.to === "B")
expect(loops).toHaveLength(3)
expect(new Set(loops.map((route) => JSON.stringify(route.points))).size).toBe(3)
},
)
@@ -966,119 +811,6 @@ describe("FlowchartDiagram", () => {
`)
})
test("wraps the real deployment chart responsively without losing content or geometry", () => {
const expected = new Map([
[60, { width: 82, height: 108 }],
[80, { width: 97, height: 85 }],
[120, { width: 143, height: 77 }],
[160, { width: 163, height: 69 }],
])
const results = [...expected].map(([budget, dimensions]) => {
const result = expectResponsiveFlowchartValid(deploymentArchitectureSource, budget)
expect(result.dimensions).toEqual(dimensions)
for (const frame of result.layout.subgraphBounds.values()) {
for (const other of result.layout.subgraphBounds.values()) {
if (frame !== other) expect(boundsIntersect(frame, other)).toBe(false)
}
}
return result.dimensions
})
for (let index = 1; index < results.length; index++) {
expect(results[index - 1]!.width).toBeLessThan(results[index]!.width)
}
})
test.each([7, 9])("wraps generated %s-node Unicode subgraph ranks across width targets", (count) => {
const results = [60, 80, 120].map(
(budget) => expectResponsiveFlowchartValid(generatedWideRankFlowchart(count), budget).dimensions,
)
for (let index = 1; index < results.length; index++) {
expect(results[index - 1]!.width).toBeLessThan(results[index]!.width)
expect(results[index - 1]!.height).toBeGreaterThanOrEqual(results[index]!.height)
}
})
test("keeps responsive local-direction subgraphs clear of sibling nodes", () => {
const layout = layoutFlowchartDiagram(
`flowchart BT
N0[Outside zero]
subgraph Outer
N2[Two]
subgraph Inner
direction LR
N3[Three]
N4[Four]
end
N5[X]
end
N7[Outside seven]
N7 --> N2
N2 -->|label 6| N5
N5 --> N4
N0 --> N7`,
{ compact: true, layoutMaxWidth: 35 },
)
const nodes = [...layout.bounds.values()]
for (let left = 0; left < nodes.length; left++) {
for (let right = left + 1; right < nodes.length; right++) {
expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
}
}
})
test("keeps responsive sibling subgraph frames and long titles disjoint", () => {
const layout = layoutFlowchartDiagram(
`flowchart TD
subgraph Parent
direction LR
subgraph Left [A deliberately long left group title]
direction LR
A1[One] --> A2[Two]
end
subgraph Right [A deliberately long right group title]
direction LR
B1[Three] --> B2[Four]
end
A2 --> B1
end`,
{ compact: true, layoutMaxWidth: 35 },
)
expect(boundsIntersect(layout.subgraphBounds.get("Left")!, layout.subgraphBounds.get("Right")!)).toBe(false)
})
test("does not change parallel routes for a non-binding width target", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
A[A] -->|one| B[B]
A -->|two| B`)
const unconstrained = layoutParsedFlowchartDiagram(diagram, { compact: true })
const nonBinding = layoutParsedFlowchartDiagram(diagram, { compact: true, layoutMaxWidth: 1_000 })
expect(nonBinding.routes.map((route) => route.points)).toEqual(unconstrained.routes.map((route) => route.points))
})
test("keeps responsive fan-out labels inside the width target", () => {
const content = `flowchart TD
subgraph Group
S[Source]
S -->|route 0 detail| N0[Node 0]
S -->|route 1 detail| N1[Node 1]
S -->|route 2 detail| N2[Node 2]
S -->|route 3 detail| N3[Node 3]
end`
const layout = layoutFlowchartDiagram(content, { compact: true, layoutMaxWidth: 30 })
const output = renderFlowchartDiagram(content, { compact: true, layoutMaxWidth: 30 })
for (const route of layout.routes) {
const label = flowchartRouteLabelLayout(route, visualLength)
expect(label.point.x + label.width).toBeLessThanOrEqual(30)
}
expect(Math.max(...output.split("\n").map((line) => stringWidth(line)))).toBeLessThanOrEqual(34)
})
test("parses Mermaid flowchart nodes and standard arrows", () => {
const diagram = parseMermaidFlowchartDiagram(`
flowchart TD
@@ -1617,22 +1349,6 @@ flowchart LR
expect(output).not.toContain("<br")
})
test.each(
(["TD", "BT"] as const).flatMap((direction) =>
[false, true].flatMap((compact) => [2, 4].map((lines) => ({ direction, compact, lines }))),
),
)(
"keeps $lines-line $direction labels off both terminal rows with compact=$compact",
({ direction, compact, lines }) => {
const label = Array.from({ length: lines }, (_, index) => `line ${index + 1}`).join("<br/>")
const route = layoutFlowchartDiagram(`flowchart ${direction}\n A[A] -->|${label}| B[B]`, { compact }).routes[0]!
const layout = flowchartRouteLabelLayout(route, visualLength)
const terminals = new Set([route.points[0]!.y, route.points.at(-1)!.y])
for (let y = layout.point.y; y < layout.point.y + layout.height; y++) expect(terminals.has(y)).toBe(false)
},
)
test("expands canvas for multiline back-edge labels", () => {
const output = renderFlowchartDiagram(`flowchart TD
A --> B
@@ -1677,10 +1393,10 @@ graph LR
expect(output).toContain("API")
expect(output).toContain("DB")
expect(output).toContain("╭─ Web App ")
expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).not.toContain("┼")
expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).toContain("┼")
})
test("breaks vertical subgraph borders where horizontal routes pass through", () => {
test("merges horizontal routes through vertical subgraph borders", () => {
const content = `flowchart LR
Outside[Outside] --> Inside
subgraph Group
@@ -1692,14 +1408,13 @@ graph LR
const group = layout.subgraphBounds.get("Group")!
const crossing = { x: group.left, y: layout.routes[0]!.points.at(-1)!.y }
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("")
expect(grid.getCell(crossing.x, crossing.y)?.style).not.toBe("group")
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("")
expect(grid.getCell(crossing.x - 1, crossing.y)?.char).toBe("─")
expect(grid.getCell(crossing.x, crossing.y - 1)?.char).toBe("│")
expect(grid.getCell(crossing.x, crossing.y + 1)?.char).toBe("│")
})
test("breaks horizontal subgraph borders where vertical routes pass through", () => {
test("merges vertical routes through horizontal subgraph borders", () => {
const content = `flowchart TD
Outside[Outside] --> Inside
subgraph Outer [O]
@@ -1713,8 +1428,7 @@ graph LR
const outer = layout.subgraphBounds.get("Outer")!
const crossing = { x: layout.routes[0]!.points[0]!.x, y: outer.top }
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("")
expect(grid.getCell(crossing.x, crossing.y)?.style).not.toBe("group")
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("")
expect(grid.getCell(crossing.x - 1, crossing.y)?.char).toBe("─")
expect(grid.getCell(crossing.x + 1, crossing.y)?.char).toBe("─")
expect(grid.getCell(crossing.x, crossing.y - 1)?.char).toBe("│")
@@ -1752,20 +1466,21 @@ graph LR
expect(output).not.toContain("<br")
})
test("keeps long Unicode subgraph titles from replacing entering arrowheads", () => {
const content = `flowchart TD
U[Up] --> A
subgraph G []
A[A]
test("merges transition lines through subgraph frame borders", () => {
const output = renderFlowchartDiagram(`
flowchart TD
subgraph Verse [verse]
direction LR
A[A] --> B[B]
C[C] --> D[D]
end
A --> D[Down]`
const diagram = parseMermaidFlowchartDiagram(content)
const layout = layoutParsedFlowchartDiagram(diagram, { compact: true })
const grid = drawParsedFlowchartDiagramGrid(diagram, { compact: true })
const entry = layout.routes.find((route) => route.edge.from === "U")!
const end = entry.points.at(-1)!
B --> Join
D --> Join
`)
const crossingLines = output.split("\n").filter((line) => line.includes("Join") || line.includes("├"))
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(entry.points.at(-2)!, end))
expect(output).toContain(" verse ")
expect(crossingLines.join("\n").match(/┼/g)).toHaveLength(2)
})
test("lays out subgraph-local directions independently from the outer flow", () => {
@@ -1897,65 +1612,6 @@ flowchart TD
}
})
test("keeps nested local-direction layouts rigid across direction and compact matrices", () => {
const directions = ["LR", "RL", "TD", "BT"] as const
for (const global of directions) {
for (const outer of directions) {
for (const inner of directions) {
for (const compact of [false, true]) {
const layout = layoutFlowchartDiagram(
`flowchart ${global}
X[X] --> A
subgraph Outer [Outer]
direction ${outer}
subgraph Inner [Inner]
direction ${inner}
A[A] --> B[B]
end
B --> C[C]
end
C --> Y[Y]`,
{ compact },
)
const nodes = [...layout.bounds.values()]
const innerFrame = layout.subgraphBounds.get("Inner")!
const outerFrame = layout.subgraphBounds.get("Outer")!
const a = layout.bounds.get("A")!
const b = layout.bounds.get("B")!
const c = layout.bounds.get("C")!
for (let left = 0; left < nodes.length; left++) {
for (let right = left + 1; right < nodes.length; right++) {
expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
}
}
expect(boundsContains(innerFrame, a)).toBe(true)
expect(boundsContains(innerFrame, b)).toBe(true)
expect(boundsContains(outerFrame, innerFrame)).toBe(true)
expect(boundsContains(outerFrame, c)).toBe(true)
expect(layout.routes.every((route) => route.points.length >= 2)).toBe(true)
expectFlowchartRoutesAvoidUnrelatedNodes(layout)
for (const frame of layout.subgraphBounds.values()) {
for (const route of layout.routes) {
expect(routeRunsAlongHorizontalBorder(route, frame)).toBe(false)
expect(routeRunsAlongVerticalBorder(route, frame)).toBe(false)
}
}
if (inner === "LR") expect(b.left).toBeGreaterThan(a.left)
if (inner === "RL") expect(b.left).toBeLessThan(a.left)
if (inner === "TD") expect(b.top).toBeGreaterThan(a.top)
if (inner === "BT") expect(b.top).toBeLessThan(a.top)
if (outer === "LR") expect(c.centerX).toBeGreaterThan(b.centerX)
if (outer === "RL") expect(c.centerX).toBeLessThan(b.centerX)
if (outer === "TD") expect(c.centerY).toBeGreaterThan(b.centerY)
if (outer === "BT") expect(c.centerY).toBeLessThan(b.centerY)
}
}
}
}
})
test("compacts stacked subgraph-local direction rows", () => {
const layout = layoutFlowchartDiagram(`
flowchart TD
@@ -2381,10 +2037,8 @@ flowchart LR
test("applies the global flowchart StyledText theme", () => {
const grid = drawFlowchartDiagramGrid("flowchart LR\n A[Alpha] --> B[Beta]")
const node = parseColor("#ff0000")
const nodeBorder = parseColor("#0000ff")
const styled = renderGridStyledText(grid, resolveFlowchartStyleColors({ node, nodeBorder }))
const styled = renderGridStyledText(grid, resolveFlowchartStyleColors({ node }))
expect(styled.chunks.some((chunk) => chunk.text.includes("Alpha") && chunk.fg?.equals(node))).toBe(true)
expect(styled.chunks.some((chunk) => chunk.text.includes("╭") && chunk.fg?.equals(nodeBorder))).toBe(true)
})
})
-6
View File
@@ -18,7 +18,6 @@ const LABEL_BUS_CLEARANCE = 3
const LABEL_NODE_CLEARANCE = 2
const LABEL_LINE_CLEARANCE = 2
const LABEL_PADDING = 1
const LABEL_TERMINAL_CLEARANCE = 1
export interface FlowchartEdgeLabelLayout {
lines: string[]
@@ -64,11 +63,6 @@ function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHei
return clampPoint(shiftPoint(shiftPoint(segment.from, segment.direction, LABEL_LINE_CLEARANCE), "up", labelHeight))
}
const slot = insetSpan(segmentSpan(segment), LABEL_TERMINAL_CLEARANCE)
if (spanCapacity(slot) >= labelHeight) {
return clampPoint(point(segment.from.x + 1, centeredSpanStart(slot, labelHeight)))
}
const center = shiftPoint(pointOnSegment(segment, midpoint(segmentSpan(segment))), "right")
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
}
+47 -245
View File
@@ -14,7 +14,7 @@ import {
flowchartVerticalBranchLabelGap,
} from "./labels.js"
import type { FlowchartDiagramRenderOptions } from "./options.js"
import { avoidFlowchartFrameBorders, routeFlowchartEdges } from "./routing.js"
import { routeFlowchartEdges } from "./routing.js"
import type {
FlowchartDiagram,
FlowchartDirection,
@@ -23,7 +23,6 @@ import type {
FlowchartNode,
FlowchartNodeBounds,
FlowchartNodeSize,
FlowchartPoint,
FlowchartSubgraphBounds,
} from "./types.js"
@@ -365,8 +364,7 @@ function layoutRankedNodes(
sizes: ReadonlyMap<string, FlowchartNodeSize>,
minNodeGap: number,
requestedMinRankGap: number,
targetWidth?: number,
): { bounds: Map<string, FlowchartNodeBounds>; wrapped: boolean } {
): Map<string, FlowchartNodeBounds> {
const horizontal = isHorizontalDirection(direction)
const ranks = rankNodes(diagram)
const maxRank = Math.max(0, ...ranks.values())
@@ -408,7 +406,6 @@ function layoutRankedNodes(
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
const bounds = new Map<string, FlowchartNodeBounds>()
let wrapped = false
if (horizontal) {
const columnWidths = rankKeys.map((rank) =>
@@ -445,141 +442,68 @@ function layoutRankedNodes(
x += columnWidth + (horizontalGaps[rankIndex] ?? 0)
}
} else {
const rankBands = rankKeys.map((rank) => {
const rowHeights = rankKeys.map((rank) =>
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.height)),
)
const rowWidths = rankKeys.map((rank) => {
const nodes = ranksByIndex.get(rank)!
const roomyNodeGap = verticalNodeGap(rank)
const naturalWidth =
return (
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
Math.max(0, nodes.length - 1) * roomyNodeGap
const labeledEdges = diagram.edges.filter(
(edge) => edge.label && (normalizedRanks.get(edge.from) === rank || normalizedRanks.get(edge.to) === rank),
Math.max(0, nodes.length - 1) * verticalNodeGap(rank)
)
const needsLabelLanes =
labeledEdges.length > 1 &&
labeledEdges.some((edge) => {
const targets = new Set(
labeledEdges.filter((candidate) => candidate.from === edge.from).map((candidate) => candidate.to),
)
const sources = new Set(
labeledEdges.filter((candidate) => candidate.to === edge.to).map((candidate) => candidate.from),
)
const grouped = (ids: readonly string[]) =>
!diagram.subgraphs?.length ||
diagram.subgraphs.some((subgraph) => ids.every((id) => subgraph.nodeIds.includes(id)))
return (
(targets.size > 1 && grouped([edge.from, ...targets])) ||
(sources.size > 1 && grouped([edge.to, ...sources]))
)
})
const nodeGap =
targetWidth !== undefined && naturalWidth > targetWidth && !needsLabelLanes ? minNodeGap : roomyNodeGap
const bands: { nodes: FlowchartNode[]; width: number; height: number }[] = []
for (const node of nodes) {
const size = sizes.get(node.id)!
const current = bands.at(-1)
const width = current ? current.width + nodeGap + size.width : size.width
if (current && targetWidth !== undefined && width > targetWidth) {
wrapped = true
bands.push({ nodes: [node], width: size.width, height: size.height })
continue
}
if (!current) {
bands.push({ nodes: [node], width: size.width, height: size.height })
continue
}
current.nodes.push(node)
current.width = width
current.height = Math.max(current.height, size.height)
}
return { bands, nodeGap }
})
const canvasWidth = Math.max(1, ...rankBands.flatMap((rank) => rank.bands.map((band) => band.width)))
const canvasWidth = Math.max(1, ...rowWidths)
let y = 0
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
const rank = rankBands[rankIndex]!
for (const [bandIndex, band] of rank.bands.entries()) {
let x = Math.floor((canvasWidth - band.width) / 2)
for (const node of band.nodes) {
const size = sizes.get(node.id)!
const top = y + Math.floor((band.height - size.height) / 2)
bounds.set(node.id, {
id: node.id,
...size,
left: x,
top,
centerX: x + Math.floor(size.width / 2),
centerY: top + Math.floor(size.height / 2),
})
x += size.width + rank.nodeGap
}
y += band.height + (bandIndex < rank.bands.length - 1 ? minNodeGap : 0)
const rank = rankKeys[rankIndex]!
const nodes = ranksByIndex.get(rank)!
const rowHeight = rowHeights[rankIndex]!
const nodeGap = verticalNodeGap(rank)
let x = Math.floor((canvasWidth - rowWidths[rankIndex]!) / 2)
for (const node of nodes) {
const size = sizes.get(node.id)!
const top = y + Math.floor((rowHeight - size.height) / 2)
bounds.set(node.id, {
id: node.id,
...size,
left: x,
top,
centerX: x + Math.floor(size.width / 2),
centerY: top + Math.floor(size.height / 2),
})
x += size.width + nodeGap
}
y += verticalGaps[rankIndex] ?? 0
y += rowHeight + (verticalGaps[rankIndex] ?? 0)
}
}
return { bounds, wrapped }
return bounds
}
function layoutLocalSubgraphDirections(
diagram: FlowchartDiagram,
nodeBounds: Map<string, FlowchartNodeBounds>,
sizes: ReadonlyMap<string, FlowchartNodeSize>,
minNodeGap: number,
requestedMinRankGap: number,
targetWidth?: number,
): boolean {
let wrapped = false
): void {
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
if (!subgraph.direction || subgraph.direction === diagram.direction) continue
const childSubgraphs = (diagram.subgraphs ?? []).filter((child) => child.parentId === subgraph.id)
const coveredNodeIds = new Set(childSubgraphs.flatMap((child) => [...collectSubgraphNodeIds(diagram, child.id)]))
const items = [
...childSubgraphs.flatMap((child) => {
const nodeIds = [...collectSubgraphNodeIds(diagram, child.id)]
const content = boundsFromChildren(nodeIds.flatMap((id) => nodeBounds.get(id) ?? []))
const bounds = content ? subgraphBoundFromChildren(child.id, child.label, [content]) : undefined
return bounds ? [{ id: `subgraph:${child.id}`, nodeIds, bounds, childId: child.id }] : []
}),
...subgraph.nodeIds.flatMap((id) => {
if (coveredNodeIds.has(id)) return []
const bounds = nodeBounds.get(id)
return bounds ? [{ id, nodeIds: [id], bounds, childId: undefined }] : []
}),
]
if (items.length === 0) continue
const nodeIds = new Set(subgraph.nodeIds)
const nodes = diagram.nodes.filter((node) => nodeIds.has(node.id))
if (nodes.length === 0) continue
const currentBounds = boundsFromChildren(items.map((item) => item.bounds))
const currentBounds = boundsFromChildren(nodes.flatMap((node) => nodeBounds.get(node.id) ?? []))
if (!currentBounds) continue
const itemByEndpoint = new Map<string, string>()
for (const item of items) {
for (const nodeId of item.nodeIds) itemByEndpoint.set(nodeId, item.id)
if (item.childId) itemByEndpoint.set(item.childId, item.id)
}
const nodes = items.map((item): FlowchartNode => ({ id: item.id, label: item.id, shape: "box" }))
const localDiagram: FlowchartDiagram = {
direction: subgraph.direction,
nodes,
edges: diagram.edges.flatMap((edge) => {
const from = itemByEndpoint.get(edge.from)
const to = itemByEndpoint.get(edge.to)
return from && to && from !== to ? [{ ...edge, from, to }] : []
}),
edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)),
subgraphs: [],
}
const itemSizes = new Map(
items.map((item) => [item.id, { width: item.bounds.width, height: item.bounds.height, lines: [item.id] }]),
)
const localLayout = layoutRankedNodes(
localDiagram,
subgraph.direction,
itemSizes,
Math.max(minNodeGap, SUBGRAPH_PADDING_X * 2 + 1),
requestedMinRankGap,
targetWidth,
)
const localBounds = localLayout.bounds
wrapped ||= localLayout.wrapped
const localNodeGap = isHorizontalDirection(subgraph.direction) ? Math.max(4, minNodeGap - 1) : minNodeGap
const localBounds = layoutRankedNodes(localDiagram, subgraph.direction, sizes, localNodeGap, requestedMinRankGap)
const localExtent = boundsFromChildren([...localBounds.values()])
if (!localExtent) continue
@@ -588,66 +512,11 @@ function layoutLocalSubgraphDirections(
const dx = targetLeft - localExtent.left
const dy = targetTop - localExtent.top
const translations = new Map<string, { dx: number; dy: number }>()
for (const item of items) {
const bound = localBounds.get(item.id)!
const itemDx = bound.left + dx - item.bounds.left
const itemDy = bound.top + dy - item.bounds.top
for (const nodeId of item.nodeIds) translations.set(nodeId, { dx: itemDx, dy: itemDy })
}
let groupOffset = { x: 0, y: 0 }
if (targetWidth !== undefined) {
const localNodeIds = new Set(translations.keys())
const external = [...nodeBounds.entries()].filter(([id]) => !localNodeIds.has(id)).map(([, bound]) => bound)
const overlaps = (offset: FlowchartPoint) =>
[...translations].some(([id, translation]) => {
const bound = nodeBounds.get(id)!
const left = bound.left + translation.dx + offset.x
const top = bound.top + translation.dy + offset.y
return external.some(
(other) =>
left < other.left + other.width + minNodeGap &&
left + bound.width + minNodeGap > other.left &&
top < other.top + other.height + minNodeGap &&
top + bound.height + minNodeGap > other.top,
)
})
if (overlaps(groupOffset)) {
const vertical = !isHorizontalDirection(diagram.direction)
const sign = diagram.direction === "RL" || diagram.direction === "BT" ? -1 : 1
let found = false
search: for (let distance = 1; distance < 1_000; distance++) {
const candidates = vertical
? [
{ x: 0, y: sign * distance },
{ x: 0, y: -sign * distance },
{ x: distance, y: 0 },
{ x: -distance, y: 0 },
]
: [
{ x: sign * distance, y: 0 },
{ x: -sign * distance, y: 0 },
{ x: 0, y: distance },
{ x: 0, y: -distance },
]
for (const candidate of candidates) {
if (overlaps(candidate)) continue
groupOffset = candidate
found = true
break search
}
}
if (!found) throw new Error(`Subgraph ${subgraph.id} has no collision-free responsive position`)
}
}
for (const [nodeId, translation] of translations) {
const nodeBound = nodeBounds.get(nodeId)
if (nodeBound) translateBounds(nodeBound, translation.dx + groupOffset.x, translation.dy + groupOffset.y)
for (const [nodeId, bound] of localBounds) {
translateBounds(bound, dx, dy)
nodeBounds.set(nodeId, bound)
}
}
return wrapped
}
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
@@ -732,7 +601,6 @@ function separateTopLevelItems(
nodeBounds: Map<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
gap: number,
targetWidth?: number,
): boolean {
const hasLocalDirection = (diagram.subgraphs ?? []).some(
(subgraph) => subgraph.direction && subgraph.direction !== diagram.direction,
@@ -866,35 +734,6 @@ function separateTopLevelItems(
crossCursor = start + shift + size + gap
}
}
if (targetWidth !== undefined && !horizontal) {
const intersects = (left: (typeof items)[number], right: (typeof items)[number]): boolean =>
[...left.nodeIds].some((leftId) => {
const leftBounds = nodeBounds.get(leftId)!
return [...right.nodeIds].some((rightId) => {
const rightBounds = nodeBounds.get(rightId)!
return (
leftBounds.left <= rightBounds.left + rightBounds.width - 1 &&
leftBounds.left + leftBounds.width - 1 >= rightBounds.left &&
leftBounds.top <= rightBounds.top + rightBounds.height - 1 &&
leftBounds.top + leftBounds.height - 1 >= rightBounds.top
)
})
})
for (let rightIndex = 1; rightIndex < items.length; rightIndex++) {
const right = items[rightIndex]!
for (let leftIndex = 0; leftIndex < rightIndex; leftIndex++) {
const left = items[leftIndex]!
if (!intersects(left, right)) continue
const leftBounds = boundsFromChildren([...left.nodeIds].map((id) => nodeBounds.get(id)!))!
const rightBounds = boundsFromChildren([...right.nodeIds].map((id) => nodeBounds.get(id)!))!
const shift = reversed
? leftBounds.top - gap - (rightBounds.top + rightBounds.height)
: leftBounds.top + leftBounds.height + gap - rightBounds.top
moved ||= shift !== 0
moveItem(right, 0, shift)
}
}
}
return moved
}
@@ -932,7 +771,6 @@ function layoutFlowchartWithDirection(
sourceDiagram: FlowchartDiagram,
options: FlowchartDiagramRenderOptions,
direction: FlowchartDirection,
responsiveFallback = false,
): FlowchartLayout {
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
const horizontal = isHorizontalDirection(direction)
@@ -948,65 +786,29 @@ function layoutFlowchartWithDirection(
: DEFAULT_MIN_VERTICAL_RANK_GAP,
)
const sizes = new Map(diagram.nodes.map((node) => [node.id, nodeSize(node)]))
const targetWidth =
!horizontal && options.layoutMaxWidth !== undefined && Number.isFinite(options.layoutMaxWidth)
? Math.max(1, Math.trunc(options.layoutMaxWidth))
: undefined
const ranked = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap, targetWidth)
const bounds = ranked.bounds
const responsive = layoutLocalSubgraphDirections(diagram, bounds, minNodeGap, requestedMinRankGap, targetWidth)
const directionAligned = responsiveFallback || responsive || ranked.wrapped
const bounds = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap)
layoutLocalSubgraphDirections(diagram, bounds, sizes, minNodeGap, requestedMinRankGap)
const subgraphs = diagram.subgraphs ?? []
let subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
let routes: FlowchartEdgeRoute[]
if (subgraphs.length === 0) {
routes = routeFlowchartEdges(
diagram,
bounds,
(edge) => edgeDirection(diagram, edge),
undefined,
targetWidth,
directionAligned,
)
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
} else {
routes = routeFlowchartEdges(
diagram,
bounds,
(edge) => edgeDirection(diagram, edge),
undefined,
targetWidth,
directionAligned,
)
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
const moved = separateTopLevelItems(
diagram,
bounds,
subgraphBounds,
Math.max(1, Math.floor(requestedMinRankGap / 2)),
targetWidth,
)
if (moved) {
routes = routeFlowchartEdges(
diagram,
bounds,
(edge) => edgeDirection(diagram, edge),
undefined,
targetWidth,
directionAligned,
)
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
}
routes = routeFlowchartEdges(
diagram,
bounds,
(edge) => edgeDirection(diagram, edge),
subgraphBounds,
targetWidth,
directionAligned,
)
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
avoidFlowchartFrameBorders(routes, bounds, subgraphBounds)
}
freezeRouteLabelPoints(routes)
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
@@ -1032,5 +834,5 @@ export function layoutFlowchartDiagram(
if (!isHorizontalDirection(direction) || maxWidth === undefined || !Number.isFinite(maxWidth)) return layout
if (layout.width <= Math.max(1, Math.trunc(maxWidth))) return layout
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD", true)
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD")
}
+1 -1
View File
@@ -7,6 +7,6 @@ export interface FlowchartDiagramRenderOptions {
borderStyle?: BorderStyle
minNodeGap?: number
minRankGap?: number
/** Target rendered width. Oversized horizontal layouts fold vertically and broad vertical ranks wrap. */
/** Fold oversized horizontal layouts vertically when their rendered width exceeds this limit. */
layoutMaxWidth?: number
}
+18 -226
View File
@@ -11,11 +11,9 @@ import {
lane,
oppositeSide,
orthogonalPath,
orthogonalPathPoints,
pathThrough,
pathViaLane,
segmentBetween,
segmentSpan,
sideForDirection,
snapCoordinate,
shiftPoint,
@@ -140,11 +138,11 @@ function horizontalEdgePath(
})
}
function selfEdgePath(bounds: FlowchartNodeBounds, laneOffset = 0): FlowchartPoint[] {
function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
const start = boundsSidePoint(bounds, "right")
const end = boundsSidePoint(bounds, "bottom")
const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE + laneOffset
const bottomLaneY = bounds.top + bounds.height + 1 + laneOffset
const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE
const bottomLaneY = bounds.top + bounds.height + 1
return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
}
@@ -524,8 +522,6 @@ function routeVerticalFanIn(
function routeParallelEdges(
diagram: FlowchartDiagram,
bounds: Map<string, FlowchartNodeBounds>,
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
directionAligned: boolean,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
@@ -534,18 +530,8 @@ function routeParallelEdges(
if (edges.length < 2) continue
const from = bounds.get(edges[0]!.from)
const to = bounds.get(edges[0]!.to)
if (!from || !to) continue
if (from.id === to.id) {
let laneOffset = 0
for (const edge of edges) {
routes.push({ edge, points: selfEdgePath(from, laneOffset) })
handled.add(edge)
laneOffset++
}
continue
}
const parallelAxis =
directionAligned && isVerticalDirection(directionForEdge(edges[0]!)) ? "x" : parallelLaneAxis(from, to)
if (!from || !to || from.id === to.id) continue
const parallelAxis = parallelLaneAxis(from, to)
let previousRoute: FlowchartEdgeRoute | undefined
for (const edge of edges) {
const height = labelHeight(edge)
@@ -553,7 +539,7 @@ function routeParallelEdges(
parallelAxis === "x"
? previousRoute
? rightRenderExtent(previousRoute) + NODE_CLEARANCE
: Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + (directionAligned ? 1 : 0)
: Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x)
: previousRoute
? Math.max(...previousRoute.points.map((point) => point.y)) + (height > 1 ? height + 1 : 1)
: Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + (height > 1 ? height : 0)
@@ -809,69 +795,6 @@ function routeIntersectsLabels(route: FlowchartEdgeRoute, labels: readonly Flowc
)
}
function pathsIntersect(left: readonly FlowchartPoint[], right: readonly FlowchartPoint[]): boolean {
const occupied = new Set(orthogonalPathPoints(left).map((point) => `${point.x}:${point.y}`))
return orthogonalPathPoints(right).some((point) => occupied.has(`${point.x}:${point.y}`))
}
function endpointDisjoint(left: FlowchartEdge, right: FlowchartEdge): boolean {
return left.from !== right.from && left.from !== right.to && left.to !== right.from && left.to !== right.to
}
function endpointConflictsWithRoutes(route: FlowchartEdgeRoute, otherRoutes: readonly FlowchartEdgeRoute[]): boolean {
const source = route.points[0]
const target = route.points.at(-1)
if (!source || !target) return false
return otherRoutes.some((other) => {
const otherSource = other.points[0]
const otherTarget = other.points.at(-1)
return (
(otherSource && target.x === otherSource.x && target.y === otherSource.y) ||
(otherTarget && source.x === otherTarget.x && source.y === otherTarget.y)
)
})
}
function pathRunsAlongFrame(points: readonly FlowchartPoint[], bounds: FlowchartSubgraphBounds): boolean {
const right = bounds.left + bounds.width - 1
const bottom = bounds.top + bounds.height - 1
for (let index = 1; index < points.length; index++) {
const segment = segmentBetween(points[index - 1]!, points[index]!)
if (!segment) continue
const span = segmentSpan(segment)
if (
segment.axis === "x" &&
(segment.from.y === bounds.top || segment.from.y === bottom) &&
Math.min(span.end, right) > Math.max(span.start, bounds.left)
) {
return true
}
if (
segment.axis === "y" &&
(segment.from.x === bounds.left || segment.from.x === right) &&
Math.min(span.end, bottom) > Math.max(span.start, bounds.top)
) {
return true
}
}
return false
}
function subgraphTitleBounds(bounds: FlowchartSubgraphBounds): {
left: number
top: number
width: number
height: number
} {
const lines = splitDiagramLines(bounds.label)
return {
left: bounds.left + 2,
top: bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length,
width: Math.max(...lines.map((line) => diagramTextWidth(` ${line} `))),
height: lines.length,
}
}
function avoidNodeObstacles(
route: FlowchartEdgeRoute,
routes: readonly FlowchartEdgeRoute[],
@@ -892,23 +815,10 @@ function avoidNodeObstacles(
const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined
return pathIntersectsBounds(candidate.points, bound, allowedContact)
})
const intersectsStructuralObstacle = (candidate: FlowchartEdgeRoute): boolean =>
intersectsNode(candidate) ||
allSubgraphBounds.some(
(bound) =>
pathRunsAlongFrame(candidate.points, bound) ||
(bound.label.length > 0 && pathIntersectsBounds(candidate.points, subgraphTitleBounds(bound))),
)
const intersectsRoutingObstacle = (candidate: FlowchartEdgeRoute): boolean =>
intersectsStructuralObstacle(candidate) ||
endpointConflictsWithRoutes(candidate, otherRoutes) ||
otherRoutes.some(
(other) => endpointDisjoint(candidate.edge, other.edge) && pathsIntersect(candidate.points, other.points),
)
const intersectsObstacle = (candidate: FlowchartEdgeRoute): boolean => {
const label = candidate.edge.label ? flowchartRouteLabelLayout(candidate, diagramTextWidth) : undefined
return (
intersectsRoutingObstacle(candidate) ||
intersectsNode(candidate) ||
allNodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
allSubgraphBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
labelIntersectsLabels(label, otherLabels) ||
@@ -929,19 +839,19 @@ function avoidNodeObstacles(
const rightBusXs = [
...new Set([
rightBusX,
...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + NODE_CLEARANCE)),
...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + BUS_CLEARANCE)),
]),
].sort((left, right) => left - right)
const leftBusXs = [
...new Set([leftBusX, ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - NODE_CLEARANCE))]),
...new Set([leftBusX, ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - BUS_CLEARANCE))]),
].sort((left, right) => right - left)
const topBusYs = [
...new Set([topBusY, ...otherLabels.map((label) => Math.min(topBusY, label.point.y - NODE_CLEARANCE))]),
...new Set([topBusY, ...otherLabels.map((label) => Math.min(topBusY, label.point.y - BUS_CLEARANCE))]),
].sort((left, right) => right - left)
const bottomBusYs = [
...new Set([
bottomBusY,
...otherLabels.map((label) => Math.max(bottomBusY, label.point.y + label.height - 1 + NODE_CLEARANCE)),
...otherLabels.map((label) => Math.max(bottomBusY, label.point.y + label.height - 1 + BUS_CLEARANCE)),
]),
].sort((left, right) => left - right)
const busLimit = Math.max(1, Math.floor(Math.sqrt(ROUTING_CANDIDATE_BUDGET / 4)))
@@ -1043,7 +953,7 @@ function avoidNodeObstacles(
if (from.id === to.id)
return (
shortest(selfLoops, (candidate) => !intersectsObstacle(candidate)) ??
shortest(selfLoops, (candidate) => !intersectsStructuralObstacle(candidate)) ??
shortest(selfLoops, (candidate) => !intersectsNode(candidate)) ??
route
)
const currentTargetSide = sideForOutsidePoint(to, route.points.at(-1)!)
@@ -1092,8 +1002,8 @@ function avoidNodeObstacles(
shortest(sameSides, (candidate) => !intersectsObstacle(candidate)) ??
shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ??
shortest(attachments, (candidate) => !intersectsObstacle(candidate)) ??
shortest(preservedSources, (candidate) => !intersectsStructuralObstacle(candidate)) ??
shortest(attachments, (candidate) => !intersectsStructuralObstacle(candidate)) ??
shortest(preservedSources, (candidate) => !intersectsNode(candidate)) ??
shortest(attachments, (candidate) => !intersectsNode(candidate)) ??
route
)
}
@@ -1102,8 +1012,8 @@ function avoidNodeObstacles(
shortest(preservedTargets, (candidate) => !intersectsObstacle(candidate)) ??
attachments.find((candidate) => !intersectsObstacle(candidate)) ??
shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ??
shortest(attachments, (candidate) => !intersectsStructuralObstacle(candidate)) ??
shortest(preservedSources, (candidate) => !intersectsStructuralObstacle(candidate)) ??
shortest(attachments, (candidate) => !intersectsNode(candidate)) ??
shortest(preservedSources, (candidate) => !intersectsNode(candidate)) ??
route
)
}
@@ -1113,8 +1023,6 @@ function avoidLabelOverlap(
otherRoutes: readonly FlowchartEdgeRoute[],
bounds: ReadonlyMap<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
targetWidth?: number,
includeLabelWidth = true,
): FlowchartEdgeRoute {
if (!route.edge.label) return route
const nodeBounds = [...bounds.values()]
@@ -1132,14 +1040,7 @@ function avoidLabelOverlap(
{ left: sourcePoint.x, top: sourcePoint.y, width: 1, height: 1 },
]
})
const hasParallelRoute = otherRoutes.some(
(other) => other.edge.from === route.edge.from && other.edge.to === route.edge.to,
)
const intersectsObstacle = (label: FlowchartEdgeLabelLayout): boolean =>
(targetWidth !== undefined &&
(hasParallelRoute || !includeLabelWidth
? label.point.x > targetWidth
: label.point.x + label.width > targetWidth)) ||
nodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
frameBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
labelIntersectsLabels(label, otherLabels) ||
@@ -1193,15 +1094,6 @@ function avoidLabelOverlap(
}
}
}
if (targetWidth !== undefined && includeLabelWidth && current.point.x + current.width > targetWidth) {
const x = Math.max(0, targetWidth - current.width)
for (let distance = 0; distance < 100; distance++) {
for (const y of distance === 0 ? [current.point.y] : [current.point.y - distance, current.point.y + distance]) {
if (y < 0 || intersectsObstacle({ ...current, point: { x, y } })) continue
return { ...route, labelPoint: { x, y } }
}
}
}
return route
}
@@ -1210,8 +1102,6 @@ export function routeFlowchartEdges(
bounds: Map<string, FlowchartNodeBounds>,
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
targetWidth?: number,
directionAligned = false,
): FlowchartEdgeRoute[] {
const routedDiagram = { ...diagram, edges: diagram.edges.filter((edge) => !edge.orderOnly) }
const handled = new Set<FlowchartEdge>()
@@ -1220,7 +1110,7 @@ export function routeFlowchartEdges(
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
: undefined
routeParallelEdges(routedDiagram, bounds, directionForEdge, directionAligned, handled, routes)
routeParallelEdges(routedDiagram, bounds, handled, routes)
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
const horizontalEdges = routedDiagram.edges.filter(
@@ -1255,109 +1145,11 @@ export function routeFlowchartEdges(
for (let index = routes.length - 1; index >= 0; index--) {
routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index)
}
const subgraphs = diagram.subgraphs ?? []
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
const containers = (id: string) => {
const ids = new Set<string>()
let current = subgraphs.find((subgraph) => subgraph.nodeIds.includes(id))
while (current) {
ids.add(current.id)
current = current.parentId ? subgraphById.get(current.parentId) : undefined
}
return ids
}
const groupedLabelEdge = (edge: FlowchartEdge) => {
const fromContainers = containers(edge.from)
if (![...containers(edge.to)].some((id) => fromContainers.has(id))) return false
const targets = new Set(
routedDiagram.edges
.filter((candidate) => candidate.label && candidate.from === edge.from)
.map((candidate) => candidate.to),
)
const sources = new Set(
routedDiagram.edges
.filter((candidate) => candidate.label && candidate.to === edge.to)
.map((candidate) => candidate.from),
)
return targets.size > 1 || sources.size > 1
}
return routes.reduce<FlowchartEdgeRoute[]>((resolved, route, index) => {
const grouped = groupedLabelEdge(route.edge)
return [
...resolved,
avoidLabelOverlap(
route,
[...resolved, ...routes.slice(index + 1)],
bounds,
subgraphBounds,
targetWidth !== undefined && subgraphs.length > 0 && grouped ? Math.max(1, targetWidth - 5) : targetWidth,
subgraphs.length === 0 || grouped,
),
]
return [...resolved, avoidLabelOverlap(route, [...resolved, ...routes.slice(index + 1)], bounds, subgraphBounds)]
}, [])
}
export function avoidFlowchartFrameBorders(
routes: readonly FlowchartEdgeRoute[],
bounds: ReadonlyMap<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
): void {
const inside = (node: FlowchartNodeBounds, frame: FlowchartSubgraphBounds) =>
node.left >= frame.left &&
node.top >= frame.top &&
node.left + node.width <= frame.left + frame.width &&
node.top + node.height <= frame.top + frame.height
for (const route of routes) {
const source = bounds.get(route.edge.from)
const target = bounds.get(route.edge.to)
for (const frame of subgraphBounds.values()) {
const inward = Boolean(source && target && inside(source, frame) && inside(target, frame))
const right = frame.left + frame.width - 1
const bottom = frame.top + frame.height - 1
const points: FlowchartPoint[] = [route.points[0]!]
for (let index = 1; index < route.points.length; index++) {
const from = route.points[index - 1]!
const to = route.points[index]!
const segment = segmentBetween(from, to)
if (!segment) continue
const span = segmentSpan(segment)
const horizontalSide =
segment.axis === "x" && Math.min(span.end, right) > Math.max(span.start, frame.left)
? segment.from.y === frame.top
? "top"
: segment.from.y === bottom
? "bottom"
: undefined
: undefined
const verticalSide =
segment.axis === "y" && Math.min(span.end, bottom) > Math.max(span.start, frame.top)
? segment.from.x === frame.left
? "left"
: segment.from.x === right
? "right"
: undefined
: undefined
if (!horizontalSide && !verticalSide) {
points.push(to)
continue
}
const offset = horizontalSide
? horizontalSide === "top"
? frame.top + (inward ? 1 : -1)
: bottom + (inward ? -1 : 1)
: verticalSide === "left"
? frame.left + (inward ? 1 : -1)
: right + (inward ? -1 : 1)
if (horizontalSide) points.push({ x: from.x, y: offset }, { x: to.x, y: offset }, to)
else points.push({ x: offset, y: from.y }, { x: offset, y: to.y }, to)
}
route.points = pathThrough(points)
}
}
}
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
if (sourcePoint.x < bounds.left) return "left"
if (sourcePoint.x >= bounds.left + bounds.width) return "right"
+3 -9
View File
@@ -10,7 +10,7 @@ import {
type DiagramRgb,
} from "../core/color/style.js"
export type FlowchartBaseCellStyle = "node" | "nodeBorder" | "database" | "databaseBorder" | "edge" | "label" | "group"
export type FlowchartBaseCellStyle = "node" | "database" | "edge" | "label" | "group"
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
@@ -22,9 +22,7 @@ export type FlowchartGrid = DiagramCanvas<FlowchartCellStyle, FlowchartCellMetad
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
export const DEFAULT_THEME_RGB = {
node: [228, 239, 232],
nodeBorder: [141, 163, 151],
database: [228, 239, 232],
databaseBorder: [141, 163, 151],
edge: [134, 225, 200],
label: [134, 225, 200],
group: [76, 99, 89],
@@ -37,20 +35,16 @@ export function resolveFlowchartStyleColors(
colors: Partial<Record<FlowchartCellStyle, RGBA | undefined>> = {},
): FlowchartStyleColors {
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
const nodeBorder = colors.nodeBorder ?? rgba(DEFAULT_THEME_RGB.nodeBorder)
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
const databaseBorder = colors.databaseBorder ?? rgba(DEFAULT_THEME_RGB.databaseBorder)
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
return {
node,
nodeBorder,
database,
databaseBorder,
edge,
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
...createColorRampTheme(NODE_EDGE_FADE_STYLES, nodeBorder, edge),
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, databaseBorder, edge),
...createColorRampTheme(NODE_EDGE_FADE_STYLES, node, edge),
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, database, edge),
}
}
-81
View File
@@ -1,81 +0,0 @@
import { expect, test } from "bun:test"
import { auditAllFixtures, auditFixture, summarizeAudits, worstAudits } from "./test/layout-audit/harness.js"
import { layoutFixtures } from "./test/layout-audit/fixtures.js"
test("audits deterministic flowchart and state layout families", () => {
const fixtures = layoutFixtures()
const flowcharts = fixtures.filter((fixture) => fixture.kind === "flowchart")
const states = fixtures.filter((fixture) => fixture.kind === "state")
expect(flowcharts.length).toBeGreaterThanOrEqual(100)
expect(states.length).toBeGreaterThanOrEqual(100)
expect(new Set(fixtures.map((fixture) => fixture.id)).size).toBe(fixtures.length)
const startedAt = performance.now()
const audits = auditAllFixtures()
const elapsedMs = performance.now() - startedAt
const violations = audits.flatMap((audit) =>
audit.violations.map((violation) => `${audit.fixture.id} @${audit.viewport}: ${violation}`),
)
const summary = summarizeAudits(audits)
expect(audits.length).toBeGreaterThanOrEqual(fixtures.length)
expect(violations).toEqual([])
expect(elapsedMs).toBeLessThan(35_000)
for (const audit of audits.filter((audit) => audit.fixture.kind === "state")) {
expect(audit.viewport).toBe(audit.fixture.profile === "short" ? 60 : audit.fixture.profile === "unicode" ? 80 : 120)
}
for (const id of ["state/chain/lr-long", "state/chain/rl-long"]) {
const audit = audits.find((candidate) => candidate.fixture.id === id)!
expect(audit.viewport).toBe(120)
expect([audit.metrics.width, audit.metrics.height, audit.metrics.overflow]).toEqual([84, 41, 0])
}
expect(
audits
.filter((audit) => audit.fixture.id === "flowchart/deployment-architecture/curated")
.map((audit) => audit.viewport),
).toEqual([60, 80, 120])
expect(summary.total.area.max).toBeLessThanOrEqual(11_011)
expect(summary.total.area.p95).toBeLessThanOrEqual(5_313)
expect(summary.total.bends.max).toBeLessThanOrEqual(30)
expect(summary.total.bends.p95).toBeLessThanOrEqual(11)
expect(summary.total.crossings.total).toBeLessThanOrEqual(40)
expect(summary.total.crossings.max).toBeLessThanOrEqual(3)
expect(summary.total.routeLength.max).toBeLessThanOrEqual(930)
expect(summary.total.routeLength.p95).toBeLessThanOrEqual(364)
expect(summary.total.sharedRouteCells.max).toBeLessThanOrEqual(547)
expect(summary.total.sharedRouteCells.p95).toBeLessThanOrEqual(122)
expect(summary.total.overflow.max).toBeLessThanOrEqual(170)
expect(summary.total.overflow.p95).toBeLessThanOrEqual(99)
expect(summary.state.crossings.total).toBe(0)
for (const fixture of [...Map.groupBy(fixtures, (candidate) => `${candidate.kind}/${candidate.family}`).values()].map(
(family) => family[0]!,
)) {
const first = auditFixture(fixture, 80)
const second = auditFixture(fixture, 80)
expect(second.output).toBe(first.output)
expect(second.metrics).toEqual(first.metrics)
expect(second.violations).toEqual(first.violations)
}
console.log(
`[layout-audit] ${fixtures.length} sources, ${audits.length} runs, ${elapsedMs.toFixed(0)}ms`,
JSON.stringify({
summary,
worst: {
area: worstAudits(audits, "area", 3).map((audit) => [audit.fixture.id, audit.viewport, audit.metrics.area]),
bends: worstAudits(audits, "bends", 3).map((audit) => [audit.fixture.id, audit.viewport, audit.metrics.bends]),
crossings: worstAudits(audits, "crossings", 3).map((audit) => [
audit.fixture.id,
audit.viewport,
audit.metrics.crossings,
]),
overflow: worstAudits(audits, "overflow", 3).map((audit) => [
audit.fixture.id,
audit.viewport,
audit.metrics.overflow,
]),
},
}),
)
}, 40_000)
+3 -5
View File
@@ -51,7 +51,7 @@ interface PreparedDiagram {
export interface MermaidMarkdownRendererOptions {
/** Use terminal-optimized diagram spacing. Defaults to true. */
compact?: boolean
/** Fold responsive horizontal diagrams that exceed this width. Defaults to 120 columns. */
/** Fold horizontal flowcharts that exceed this width. Defaults to 120 columns. */
layoutMaxWidth?: number
/** Gantt-specific terminal rendering options. */
gantt?: Omit<GanttDiagramRenderOptions, "layoutMaxWidth">
@@ -141,9 +141,7 @@ function prepareDiagram(
grid,
resolveFlowchartStyleColors({
node: color(colors.primary),
nodeBorder: color(colors.muted),
database: color(colors.primary),
databaseBorder: color(colors.muted),
edge: color(colors.secondary),
label: color(colors.text),
group: color(colors.muted),
@@ -217,8 +215,8 @@ function prepareDiagram(
}
}
case "state": {
const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source), { layoutMaxWidth })
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source))
const size = grid.getTextSize({ trimBottom: true })
return {
kind,
source,
+11 -263
View File
@@ -3,7 +3,7 @@ import stringWidth from "string-width"
import { spatialPathClaim } from "../core/spatial.js"
import { expectDiagram } from "../test/diagram.js"
import { renderStateDiagram } from "./diagram.js"
import { createStateDiagramDrawing, drawStateDiagramGrid } from "./drawing.js"
import { drawStateDiagramGrid } from "./drawing.js"
import { createStateDiagramLayout } from "./layout.js"
import { parseMermaidStateDiagram } from "./parser.js"
import { prepareVisibleStateDiagram } from "./visible-model.js"
@@ -60,35 +60,6 @@ function expectCompleteStateDiagram(source: string, output = renderStateDiagram(
}
}
type ResponsiveStateLabelProfile = "short" | "long" | "unicode"
function responsiveStateChain(direction: "LR" | "RL", profile: ResponsiveStateLabelProfile): string {
const stateLabel = (id: string) => {
if (profile === "long") return `${id} deliberate state with a long descriptive label`
if (profile === "unicode") return `${id} 東京<br/>résumé 🚀`
return `${id} node`
}
const transitionLabel = (id: string) => {
if (profile === "long") return `${id} transition carrying detailed context`
if (profile === "unicode") return `${id} 東京<br/>✓ prêt`
return `${id} edge`
}
const ids = ["A", "B", "C", "D", "E"]
return [
"stateDiagram-v2",
`direction ${direction}`,
...ids.map((id) => `state "${stateLabel(id)}" as ${id}`),
"[*] --> A",
...ids.slice(0, -1).map((id, index) => `${id} --> ${ids[index + 1]}: ${transitionLabel(`E0${index + 1}`)}`),
"E --> [*]",
].join("\n")
}
function renderedStateDimensions(output: string) {
const lines = output.split("\n")
return { width: Math.max(...lines.map((line) => stringWidth(line))), height: lines.length }
}
describe("StateDiagram", () => {
test("detects and parses Mermaid state diagrams", () => {
const diagram = parseMermaidStateDiagram(`
@@ -208,95 +179,6 @@ stateDiagram-v2
expect(output).toContain("◀")
})
test("renders reverse vertical direction from bottom to top", () => {
const source = `stateDiagram-v2
direction BT
A --> B`
const drawing = createStateDiagramDrawing(parseMermaidStateDiagram(source))
expect(drawing.layout.bounds.get("A")!.top).toBeGreaterThan(drawing.layout.bounds.get("B")!.top)
expect(drawing.grid.toString({ trimTop: true, trimBottom: true })).toContain("▲")
})
test.each(
(["LR", "RL"] as const).flatMap((direction) =>
(["short", "long", "unicode"] as const).flatMap((profile) =>
([60, 80, 120] as const).map((layoutMaxWidth) => [direction, profile, layoutMaxWidth] as const),
),
),
)("folds responsive %s %s chains at %d columns", (direction, profile, layoutMaxWidth) => {
const source = responsiveStateChain(direction, profile)
const horizontal = renderStateDiagram(source)
const responsive = renderStateDiagram(source, { layoutMaxWidth })
const vertical = renderStateDiagram(source, { direction: direction === "RL" ? "BT" : "TB" })
expect(renderedStateDimensions(horizontal).width).toBeGreaterThan(layoutMaxWidth)
expect(responsive).toBe(vertical)
expect(renderedStateDimensions(responsive).width).toBeLessThan(renderedStateDimensions(horizontal).width)
for (const content of ["A", "B", "C", "D", "E", "E01", "E02", "E03", "E04"]) {
expect(responsive).toContain(content)
}
})
test.each(["LR", "RL"] as const)("keeps the narrower %s orientation for broad ranks", (direction) => {
const source = `stateDiagram-v2
direction ${direction}
${Array.from({ length: 8 }, (_, index) => ` A --> B${index}`).join("\n")}`
const horizontal = renderStateDiagram(source)
const vertical = renderStateDiagram(source, { direction: direction === "RL" ? "BT" : "TB" })
const responsive = renderStateDiagram(source, { layoutMaxWidth: 60 })
expect(renderedStateDimensions(horizontal).width).toBeLessThan(renderedStateDimensions(vertical).width)
expect(responsive).toBe(horizontal)
})
test("falls back before allocating an oversized horizontal canvas", () => {
const ids = Array.from({ length: 301 }, (_, index) => `S${index}`)
const label = "transition label carrying enough context to make the horizontal canvas too large"
const source = `stateDiagram-v2
direction LR
${ids
.slice(0, -1)
.map((id, index) => ` ${id} --> ${ids[index + 1]}: ${label}`)
.join("\n")}`
const output = renderStateDiagram(source, { layoutMaxWidth: 80 })
expect(output).toContain("S0")
expect(output).toContain("S300")
expect(renderedStateDimensions(output).width).toBeLessThanOrEqual(stringWidth(label) + 8)
})
test.each(["TB", "TD", "BT"] as const)("preserves explicit %s layouts under a narrow width target", (direction) => {
const source = `stateDiagram-v2
direction ${direction}
A --> B: next`
expect(renderStateDiagram(source, { layoutMaxWidth: 1 })).toBe(renderStateDiagram(source))
})
test("preserves horizontal layouts that fit or have no finite width target", () => {
const source = `stateDiagram-v2
direction LR
A --> B`
const output = renderStateDiagram(source)
expect(renderStateDiagram(source, { layoutMaxWidth: 120 })).toBe(output)
expect(renderStateDiagram(source, { layoutMaxWidth: Number.POSITIVE_INFINITY })).toBe(output)
})
test("treats a single irreducibly wide state as soft overflow", () => {
const label = "界".repeat(40)
const output = renderStateDiagram(
`stateDiagram-v2
direction LR
state "${label}" as Wide`,
{ layoutMaxWidth: 60 },
)
expect(renderedStateDimensions(output).width).toBeGreaterThan(60)
expect(output).toContain(label)
})
test("does not mutate a parsed diagram when rendering with a direction override", () => {
const diagram = parseMermaidStateDiagram(`stateDiagram-v2
direction LR
@@ -367,21 +249,24 @@ ${ids
for (const line of labelLines) expect(output.split(line)).toHaveLength(2)
expect(output).toMatchInlineSnapshot(`
" create from base image
"
create from base image
Running
💥 sandbox dies BEFORE hook fires
(crash, our bug, race)
💥 sandbox dies BEFORE hook fires
(crash, our bug, race)
Dormant Lost
📸 suspend hook fires wake from LAST snapshot
(WE must call it on idle) files since then GONE
📸 suspend hook fires
(WE must call it on idle)
wake from snapshot image
(apt installs restored!)"
(apt installs restored!)
wake from LAST snapshot
files since then GONE"
`)
})
@@ -620,14 +505,6 @@ stateDiagram-v2
expect(output.split("\n").filter((line) => line.trim())).toHaveLength(3)
})
test.each(["LR", "RL"] as const)("trims leading rows from standalone %s choices", (direction) => {
const output = renderStateDiagram(`stateDiagram-v2
direction ${direction}
state Decision <<choice>>`)
expect(output).toBe("◆")
})
test("renders parallel transitions without losing labels", () => {
const horizontal = renderStateDiagram(`stateDiagram-v2
direction LR
@@ -806,135 +683,6 @@ stateDiagram-v2
}
})
test("grows parallel vertical diagrams by the maximum label width rather than their sum", () => {
const render = (labels: readonly string[]) =>
renderStateDiagram(`stateDiagram-v2
direction TB
${labels.map((label) => ` A --> B: ${label}`).join("\n")}`)
const shortLabels = ["one", "two", "three"]
const longLabels = [
"alpha route label that is deliberately long",
"beta route label that is deliberately long",
"gamma route label that is deliberately long",
]
const width = (output: string) => Math.max(...output.split("\n").map((line) => stringWidth(line)))
const labelGrowth =
Math.max(...longLabels.map((label) => stringWidth(label))) -
Math.max(...shortLabels.map((label) => stringWidth(label)))
expect(width(render(longLabels)) - width(render(shortLabels))).toBeLessThanOrEqual(labelGrowth + 2)
})
test("keeps audited parallel labels clear of frames and rails", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction TB
A --> B: alpha route label that is deliberately long
A --> B: beta route label that is deliberately long
A --> B: gamma route label that is deliberately long`)
expect(output).toMatchInlineSnapshot(`
" alpha route label that is deliberately long
A
gamma route label that is deliberately long
beta route label that is deliberately long
B
"
`)
})
test("keeps audited repeated self-transition lanes distinct and readable", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction LR
A --> A: one
A --> A: two
A --> A: three`)
expect(output).toMatchInlineSnapshot(`
"
A
one
two three
"
`)
})
test("expands composites around self-transition labels without engulfing external states", () => {
const source = `stateDiagram-v2
direction LR
state Outer {
A --> A: loop-0
A --> A: loop-1
A --> A: loop-2
}
A --> C`
const drawing = createStateDiagramDrawing(parseMermaidStateDiagram(source))
const outer = drawing.layout.compositeBounds.get("Outer")!
const external = drawing.layout.bounds.get("C")!
const output = drawing.grid.toString({ trimTop: true, trimBottom: true })
expect(
external.left < outer.left + outer.width &&
external.left + external.width > outer.left &&
external.top < outer.top + outer.height &&
external.top + external.height > outer.top,
).toBe(false)
for (const plan of drawing.transitionPlans.filter(
(plan) => plan.route.transition.from === plan.route.transition.to,
)) {
expect(plan.label).toBeDefined()
expect(plan.label!.x).toBeGreaterThan(outer.left)
expect(plan.label!.y).toBeGreaterThan(outer.top)
expect(plan.label!.x + Math.max(...plan.label!.lines.map((line) => stringWidth(line)))).toBeLessThan(
outer.left + outer.width,
)
expect(plan.label!.y + plan.label!.lines.length).toBeLessThan(outer.top + outer.height)
}
for (const label of ["loop-0", "loop-1", "loop-2"]) expect(output.match(new RegExp(label, "g"))).toHaveLength(1)
})
test("keeps audited nested note connectors direct and inside every frame", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction TB
state Outer {
state Inner {
A --> B: down
B --> A: up
note right of B: note
}
}`)
expect(output).toMatchInlineSnapshot(`
" Outer
Inner
A
down
B note
up
"
`)
})
test("keeps explicit choices visible in choice-only cycles", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction TB
+37 -72
View File
@@ -1,5 +1,5 @@
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
import { DiagramCanvas, DiagramCanvasSizeError, type DiagramCanvasCell } from "../core/canvas.js"
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
import { directionBetween, orthogonalPathPoints, type DiagramDirection } from "../core/geometry.js"
import {
diagramArrowHead,
@@ -12,7 +12,6 @@ import {
createStateDiagramLayout,
expandCompositeBoundsForFeedback,
expandCompositeBoundsForInternalTransitions,
separateExternalBoundsFromComposites,
translateStateDiagramLayout,
type StateDiagramBoxBounds as BoxBounds,
type StateDiagramNoteBounds as StateNoteBounds,
@@ -68,11 +67,23 @@ function makeGrid(width: number, height: number): StateGrid {
})
}
function setCell(grid: StateGrid, x: number, y: number, char: string, style?: StateCellStyle): void {
function setCell(
grid: StateGrid,
x: number,
y: number,
char: string,
style?: StateCellStyle,
): void {
grid.setCell(x, y, char, style)
}
function setText(grid: StateGrid, x: number, y: number, text: string, style?: StateCellStyle): void {
function setText(
grid: StateGrid,
x: number,
y: number,
text: string,
style?: StateCellStyle,
): void {
grid.setText(x, y, text, style)
}
@@ -97,7 +108,12 @@ function drawBox(
})
}
function drawStateFrame(grid: StateGrid, bounds: BoxBounds, chars: BorderCharacters, style: StateCellStyle): void {
function drawStateFrame(
grid: StateGrid,
bounds: BoxBounds,
chars: BorderCharacters,
style: StateCellStyle,
): void {
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style))
}
@@ -109,10 +125,6 @@ function drawContainerFrame(
style: StateCellStyle,
): void {
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style))
drawContainerLabel(grid, bounds, label, style)
}
function drawContainerLabel(grid: StateGrid, bounds: BoxBounds, label: string, style: StateCellStyle): void {
if (label) setText(grid, bounds.left + 2, bounds.top, ` ${label} `, style)
}
@@ -178,7 +190,9 @@ function drawTransitionRenderPlan(
setCell(grid, cell.x, cell.y, char, departure.get(`${cell.x}:${cell.y}`) ?? "transition")
}
if (plan.label) {
plan.label.lines.forEach((line, index) => setText(grid, plan.label!.x, plan.label!.y + index, line, "label"))
plan.label.lines.forEach((line, index) =>
setText(grid, plan.label!.x, plan.label!.y + index, line, "label"),
)
}
}
@@ -196,49 +210,8 @@ function drawTransitionJunctionPlans(
}
export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: StateDiagramRenderOptions = {}): StateGrid {
return createStateDiagramDrawing(sourceDiagram, options).grid
}
export function createStateDiagramDrawing(sourceDiagram: StateDiagram, options: StateDiagramRenderOptions = {}) {
const direction = options.direction ?? sourceDiagram.direction
if (direction !== "LR" && direction !== "RL") {
return createStateDiagramDrawingWithDirection(sourceDiagram, options, direction)
}
if (options.layoutMaxWidth === undefined || !Number.isFinite(options.layoutMaxWidth)) {
return createStateDiagramDrawingWithDirection(sourceDiagram, options, direction)
}
const fallbackDirection = direction === "RL" ? "BT" : "TB"
const maxWidth = Math.max(1, Math.trunc(options.layoutMaxWidth))
const drawing = (() => {
try {
return createStateDiagramDrawingWithDirection(sourceDiagram, options, direction)
} catch (error) {
if (error instanceof DiagramCanvasSizeError) return undefined
throw error
}
})()
if (!drawing) return createStateDiagramDrawingWithDirection(sourceDiagram, options, fallbackDirection)
if (drawing.grid.getTextSize({ trimTop: true, trimBottom: true }).width <= maxWidth) {
return drawing
}
const fallback = createStateDiagramDrawingWithDirection(sourceDiagram, options, fallbackDirection)
if (
fallback.grid.getTextSize({ trimTop: true, trimBottom: true }).width >=
drawing.grid.getTextSize({ trimTop: true, trimBottom: true }).width
) {
return drawing
}
return fallback
}
function createStateDiagramDrawingWithDirection(
sourceDiagram: StateDiagram,
options: StateDiagramRenderOptions,
direction: StateDiagram["direction"],
) {
const diagram = prepareVisibleStateDiagram(
direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction },
)
const directedDiagram = options.direction ? { ...sourceDiagram, direction: options.direction } : sourceDiagram
const diagram = prepareVisibleStateDiagram(directedDiagram)
const borderStyle = options.borderStyle ?? DEFAULT_STATE_BORDER_STYLE
const arrowHeadStyle = options.arrowHeadStyle ?? DEFAULT_STATE_ARROW_HEAD_STYLE
const minStateGap = normalizeStateMinStateGap(options.minStateGap)
@@ -253,24 +226,21 @@ function createStateDiagramDrawingWithDirection(
const feedbackLaneY = maxY + 3
const feedbackTopY = Math.min(0, ...allBounds.map((bound) => bound.top)) - 3
expandCompositeBoundsForFeedback(diagram, bounds, compositeBounds, feedbackLaneY)
let transitionPlans: StateTransitionRenderPlan[] = []
const separationAttempts = diagram.states.length + diagram.composites.length + 1
for (let attempt = 0; attempt < separationAttempts; attempt++) {
transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, {
feedbackTopY,
noteBounds,
searchBudget,
})
expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
if (!separateExternalBoundsFromComposites(diagram, layout)) break
if (attempt === separationAttempts - 1) throw new Error("State composite separation did not converge")
}
let transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, {
feedbackTopY,
noteBounds,
searchBudget,
})
expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
const connectorPoints = noteBounds.flatMap((bound) => bound.connector?.points ?? [])
const contentLeft = Math.min(
0,
...[...bounds.values(), ...noteBounds].map((bound) => bound.left),
...connectorPoints.map((point) => point.x),
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.x), ...(plan.label ? [plan.label.x] : [])]),
...transitionPlans.flatMap((plan) => [
...plan.cells.map((cell) => cell.x),
...(plan.label ? [plan.label.x] : []),
]),
)
const contentTop = Math.min(
0,
@@ -335,15 +305,10 @@ function createStateDiagramDrawingWithDirection(
drawTransitionJunctionPlans(grid, diagram, bounds, transitionPlans)
for (const composite of diagram.composites) {
const bound = compositeBounds.get(composite.id)
if (bound) drawContainerLabel(grid, bound, composite.label, "composite")
}
for (const noteBound of noteBounds) {
const target = bounds.get(noteBound.note.target)
if (target) drawNote(grid, noteBound, target)
}
return { grid, diagram, layout, transitionPlans }
return grid
}
+1 -72
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { spatialPathClaim } from "../core/spatial.js"
import { diagramTextWidth } from "../core/text.js"
import type { StateDiagram } from "./types.js"
import { createStateDiagramLayout, expandCompositeBoundsForInternalTransitions } from "./layout.js"
import { createStateDiagramLayout } from "./layout.js"
import { stateDiagramNoteConnector } from "./note.js"
import { parseMermaidStateDiagram } from "./parser.js"
import { createStateTransitionRenderPlans } from "./routing.js"
@@ -192,75 +192,4 @@ describe("StateDiagramLayout", () => {
)
expect(plans.every((plan) => plan.path.every(([x, y]) => !noteCells.has(`${x}:${y}`)))).toBe(true)
})
test.each([
["LR", -1],
["RL", 1],
] as const)("keeps a reciprocal pair on the %s axis", (direction, expectedSign) => {
const diagram = prepareVisibleStateDiagram(
parseMermaidStateDiagram(`stateDiagram-v2
direction ${direction}
A --> B: forward
B --> A: backward`),
)
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const a = layout.bounds.get("A")!
const b = layout.bounds.get("B")!
expect(a.centerY).toBe(b.centerY)
expect(Math.sign(a.centerX - b.centerX)).toBe(expectedSign)
})
test.each(["TB", "TD"] as const)(
"contains nested internal feedback with strict margins in %s diagrams",
(direction) => {
const diagram = prepareVisibleStateDiagram(
parseMermaidStateDiagram(`stateDiagram-v2
direction ${direction}
state Outer {
state Inner {
A --> B: down
B --> A: up
note right of B: nested note
}
}`),
)
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30, { noteBounds: layout.noteBounds })
expandCompositeBoundsForInternalTransitions(diagram, layout.compositeBounds, plans)
const inner = layout.compositeBounds.get("Inner")!
const outer = layout.compositeBounds.get("Outer")!
const note = layout.noteBounds[0]!
for (const composite of [inner, outer]) {
for (const plan of plans) {
expect(
plan.path.every(
([x, y]) =>
x > composite.left &&
x < composite.left + composite.width - 1 &&
y > composite.top &&
y < composite.top + composite.height - 1,
),
).toBe(true)
}
expect(
note.connector!.points.every(
(point) =>
point.x > composite.left &&
point.x < composite.left + composite.width - 1 &&
point.y > composite.top &&
point.y < composite.top + composite.height - 1,
),
).toBe(true)
}
expect(new Set(note.connector!.points.map((point) => point.y))).toEqual(
new Set([layout.bounds.get("B")!.centerY]),
)
expect(inner.left - outer.left).toBeGreaterThanOrEqual(2)
expect(inner.top - outer.top).toBeGreaterThanOrEqual(2)
expect(outer.left + outer.width - (inner.left + inner.width)).toBeGreaterThanOrEqual(2)
expect(outer.top + outer.height - (inner.top + inner.height)).toBeGreaterThanOrEqual(2)
},
)
})
+34 -170
View File
@@ -131,8 +131,7 @@ function computeMainPath(diagram: StateDiagram): string[] {
const fromParent = statesById.get(current)?.parentId
const toParent = statesById.get(transition.to)?.parentId
return Boolean(fromParent && toParent && fromParent !== toParent)
}) ??
(path.length === 1 && candidates.length === 1 ? candidates[0] : undefined)
})
if (!next) break
path.push(next.to)
visited.add(next.to)
@@ -320,12 +319,7 @@ function findNoteConnector(
const isFree = (point: DiagramPoint): boolean =>
point.x >= 0 &&
!search.blocked.has(`${point.x}:${point.y}`) &&
!(
point.x >= bounds.left &&
point.x < bounds.left + bounds.width &&
point.y >= bounds.top &&
point.y < bounds.top + bounds.height
)
!(point.x >= bounds.left && point.x < bounds.left + bounds.width && point.y >= bounds.top && point.y < bounds.top + bounds.height)
if (!isFree(end) || !isFree(goal)) return undefined
for (const start of starts.filter(isFree)) {
@@ -341,7 +335,14 @@ function findNoteConnector(
const minY = Math.min(target.top, bounds.top, search.minY) - margin
const maxX = Math.max(target.left + target.width, bounds.left + bounds.width, search.maxX) + margin
const maxY = Math.max(target.top + target.height, bounds.top + bounds.height, search.maxY) + margin
const path = findStateManhattanPath(starts, goal, search, { minX: 0, minY, maxX, maxY }, budget, isFree)
const path = findStateManhattanPath(
starts,
goal,
search,
{ minX: 0, minY, maxX, maxY },
budget,
isFree,
)
return path ? { connectorY, points: [...path, end] } : undefined
}
@@ -374,27 +375,13 @@ function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagr
)
if (descendantNotes.length === 0) continue
const connectorPoints = descendantNotes.flatMap((note) => note.connector?.points ?? [])
const left = Math.min(
bound.left,
...descendantNotes.map((note) => note.left - 2),
...connectorPoints.map((point) => point.x - 1),
)
const top = Math.min(
bound.top,
...descendantNotes.map((note) => note.top - 1),
...connectorPoints.map((point) => point.y - 1),
)
const right = Math.max(
bound.left + bound.width,
...descendantNotes.map((note) => note.left + note.width + 2),
...connectorPoints.map((point) => point.x + 2),
)
const bottom = Math.max(
bound.top + bound.height,
...descendantNotes.map((note) => note.top + note.height + 1),
...connectorPoints.map((point) => point.y + 2),
)
const childBounds = [bound, ...descendantNotes]
const noteTop = Math.min(...childBounds.map((child) => child.top), bound.top)
const noteBottom = Math.max(...childBounds.map((child) => child.top + child.height), bound.top + bound.height)
const left = Math.min(...childBounds.map((child) => child.left)) - 2
const top = noteTop < bound.top ? noteTop - 1 : bound.top
const right = Math.max(...childBounds.map((child) => child.left + child.width)) + 2
const bottom = noteBottom > bound.top + bound.height ? noteBottom + 1 : bound.top + bound.height
bound.left = left
bound.top = top
@@ -405,112 +392,13 @@ function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagr
}
}
function expandCompositeBoundsForInternalRouting(diagram: StateDiagram, layout: StateDiagramLayout): void {
if (diagram.direction === "LR" || diagram.direction === "RL") return
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
for (const composite of [...diagram.composites].reverse()) {
const bound = layout.compositeBounds.get(composite.id)
if (!bound) continue
const internal = diagram.transitions.filter(
(transition) =>
transition.from !== transition.to &&
innermostCommonCompositeId(transition, statesById, compositesById) === composite.id,
)
const endpointOccurrences = new Map<string, number>()
const sideRoutes = internal.filter((transition) => {
const from = layout.bounds.get(transition.from)
const to = layout.bounds.get(transition.to)
if (!from || !to) return false
const key = `${transition.from}\u0000${transition.to}`
const occurrence = endpointOccurrences.get(key) ?? 0
endpointOccurrences.set(key, occurrence + 1)
const fromParent = statesById.get(transition.from)?.parentId
const toParent = statesById.get(transition.to)?.parentId
return occurrence > 0 || from.centerY > to.centerY || fromParent !== toParent
})
if (sideRoutes.length === 0) continue
const childRight = Math.max(
...diagram.states.flatMap((state) => {
if (!belongsToComposite(state.id, composite.id, statesById, compositesById)) return []
const child = layout.bounds.get(state.id)
return child ? [child.left + child.width] : []
}),
...diagram.composites.flatMap((childComposite) => {
if (childComposite.parentId !== composite.id) return []
const child = layout.compositeBounds.get(childComposite.id)
return child ? [child.left + child.width] : []
}),
)
const labelWidth = Math.max(...sideRoutes.map((transition) => measureStateTransitionLabel(transition.label).width))
const right = childRight + labelWidth + sideRoutes.length * 3 + 6
if (right <= bound.left + bound.width) continue
bound.width = right - bound.left
bound.centerX = bound.left + Math.floor(bound.width / 2)
}
enforceCompositeMargins(diagram, layout.compositeBounds)
}
function innermostCommonCompositeId(
transition: StateDiagramTransition,
statesById: Map<string, StateDiagramState>,
compositesById: Map<string, StateDiagramCompositeState>,
): string | undefined {
const containers = (id: string) => {
const ids: string[] = []
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
while (parentId) {
ids.push(parentId)
parentId = compositesById.get(parentId)?.parentId
}
return ids
}
const target = new Set(containers(transition.to))
return containers(transition.from).find((id) => target.has(id))
}
function enforceCompositeMargins(diagram: StateDiagram, compositeBounds: Map<string, StateDiagramBoxBounds>): void {
const compositesByParent = new Map<string, StateDiagramCompositeState[]>()
for (const composite of diagram.composites) {
if (!composite.parentId) continue
const children = compositesByParent.get(composite.parentId) ?? []
children.push(composite)
compositesByParent.set(composite.parentId, children)
}
const expand = (composite: StateDiagramCompositeState): StateDiagramBoxBounds | undefined => {
const bound = compositeBounds.get(composite.id)
if (!bound) return undefined
const children = (compositesByParent.get(composite.id) ?? [])
.map(expand)
.filter((child): child is StateDiagramBoxBounds => Boolean(child))
if (children.length === 0) return bound
const left = Math.min(bound.left, ...children.map((child) => child.left - 2))
const top = Math.min(bound.top, ...children.map((child) => child.top - 2))
const right = Math.max(bound.left + bound.width, ...children.map((child) => child.left + child.width + 2))
const bottom = Math.max(bound.top + bound.height, ...children.map((child) => child.top + child.height + 2))
bound.left = left
bound.top = top
bound.width = right - left
bound.height = bottom - top
bound.centerX = left + Math.floor(bound.width / 2)
bound.centerY = top + Math.floor(bound.height / 2)
return bound
}
for (const composite of diagram.composites.filter((candidate) => !candidate.parentId)) expand(composite)
}
function boundsIntersect(left: StateDiagramBoxBounds, right: StateDiagramBoxBounds): boolean {
return intersects(left.left, left.top, left.width, left.height, right, 0)
}
export function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: StateDiagramLayout): boolean {
function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: StateDiagramLayout): void {
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
let shifted = false
for (const composite of diagram.composites) {
const compositeBound = layout.compositeBounds.get(composite.id)
@@ -545,10 +433,8 @@ export function separateExternalBoundsFromComposites(diagram: StateDiagram, layo
}
shiftBounds(uniqueBounds(boundsToShift), dx, 0)
shifted = true
}
}
return shifted
}
function finalizeLayout(
@@ -559,7 +445,6 @@ function finalizeLayout(
if (diagram.composites.length === 0 && diagram.notes.length === 0) return layout
addCompositeBounds(diagram, layout)
normalizeLayout(layout)
expandCompositeBoundsForInternalRouting(diagram, layout)
if (diagram.notes.length > 0) {
const allBounds = [...layout.bounds.values()]
placeStateDiagramNotesAroundTransitions(
@@ -579,7 +464,6 @@ function finalizeLayout(
)
}
expandCompositeBoundsForNotes(diagram, layout)
expandCompositeBoundsForInternalRouting(diagram, layout)
separateExternalBoundsFromComposites(diagram, layout)
normalizeLayout(layout)
return layout
@@ -595,10 +479,9 @@ export function createStateDiagramLayout(
}
const ranks = computeRanks(diagram)
const maxRank = Math.max(0, ...ranks.values())
const byRank = new Map<number, StateDiagramState[]>()
for (const state of diagram.states) {
const rank = diagram.direction === "BT" ? maxRank - (ranks.get(state.id) ?? 0) : (ranks.get(state.id) ?? 0)
const rank = ranks.get(state.id) ?? 0
const list = byRank.get(rank) ?? []
list.push(state)
byRank.set(rank, list)
@@ -608,13 +491,9 @@ export function createStateDiagramLayout(
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
const bounds = new Map<string, StateDiagramBoxBounds>()
const outgoingLabelRows = new Map<string, number>()
const selfTransitionCounts = new Map<string, number>()
for (const transition of diagram.transitions) {
const rows = measureStateTransitionLabel(transition.label).height
outgoingLabelRows.set(transition.from, Math.max(outgoingLabelRows.get(transition.from) ?? 0, rows))
if (transition.from === transition.to) {
selfTransitionCounts.set(transition.from, (selfTransitionCounts.get(transition.from) ?? 0) + 1)
}
}
const singleColumnCenter = Math.max(
@@ -645,12 +524,8 @@ export function createStateDiagramLayout(
x += size.width + options.minStateGap + 8
}
const labelRows = states.reduce((rows, state) => Math.max(rows, outgoingLabelRows.get(state.id) ?? 0), 0)
const selfTransitionRows = states.reduce(
(rows, state) => Math.max(rows, (selfTransitionCounts.get(state.id) ?? 0) * 3 + 1),
0,
)
const pseudoStateApproachClearance = states.some((state) => state.kind === "choice") ? 2 : 0
y += rowHeight + Math.max(4, labelRows + 3, selfTransitionRows) + pseudoStateApproachClearance
y += rowHeight + Math.max(4, labelRows + 3) + pseudoStateApproachClearance
}
return finalizeLayout(diagram, emptyLayout(bounds, sizes), budget)
@@ -930,21 +805,21 @@ function placeStateDiagramNotesAroundTransitions(
size,
),
),
).flat()
)
.flat()
const findPlacement = (candidateSpace: SpatialIndex, limit: number) => {
const connectorSearch = createStateSearchSpace(candidateSpace, (role) => (role === "label" ? 1 : 0))
for (const bound of candidateBounds.slice(0, limit)) {
if (bound.left < 0) continue
const owner = `note:${index}`
if (!candidateSpace.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 })) continue
if (!candidateSpace.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }))
continue
const connector = findNoteConnector(connectorSearch, bound, target, budget)
if (connector) return { bound: { ...bound, connector }, connector }
}
return undefined
}
const placement =
findPlacement(space, 1) ??
findPlacement(noteSpace, 1) ??
findPlacement(space, MAX_STRICT_NOTE_PLACEMENTS) ??
findPlacement(reserved, candidateBounds.length) ??
outsideNotePlacement(noteSpace, note, index, target, size)
@@ -982,7 +857,10 @@ function outsideNotePlacement(
size,
)
const alignedNoteX = position === "left" ? aligned.left + aligned.width : aligned.left - 1
const alignedConnectorY = Math.max(aligned.top + 1, Math.min(target.centerY, aligned.top + aligned.height - 2))
const alignedConnectorY = Math.max(
aligned.top + 1,
Math.min(target.centerY, aligned.top + aligned.height - 2),
)
const alignedTargetX = position === "left" ? target.left - 1 : target.left + target.width
const alignedConnector = {
connectorY: alignedConnectorY,
@@ -1097,30 +975,16 @@ export function expandCompositeBoundsForInternalTransitions(
belongsToComposite(plan.route.transition.from, composite.id, statesById, compositesById) &&
belongsToComposite(plan.route.transition.to, composite.id, statesById, compositesById),
)
const occupied = internalPlans.flatMap((plan) => [
...plan.cells.map((cell) => ({ x: cell.x, y: cell.y })),
...(plan.label
? plan.label.lines.flatMap((line, row) =>
Array.from({ length: diagramTextWidth(line) }, (_, column) => ({
x: plan.label!.x + column,
y: plan.label!.y + row,
})),
)
: []),
const occupiedYs = internalPlans.flatMap((plan) => [
...plan.cells.map((cell) => cell.y),
...(plan.label ? plan.label.lines.map((_, index) => plan.label!.y + index) : []),
])
if (occupied.length === 0) continue
if (occupiedYs.length === 0) continue
const left = Math.min(bound.left, Math.min(...occupied.map((point) => point.x)) - 1)
const top = Math.min(bound.top, Math.min(...occupied.map((point) => point.y)) - 1)
const right = Math.max(bound.left + bound.width, Math.max(...occupied.map((point) => point.x)) + 2)
const bottom = Math.max(bound.top + bound.height, Math.max(...occupied.map((point) => point.y)) + 2)
bound.left = left
const top = Math.min(bound.top, Math.min(...occupiedYs) - 1)
const bottom = Math.max(bound.top + bound.height, Math.max(...occupiedYs) + 2)
bound.top = top
bound.width = right - left
bound.height = bottom - top
bound.centerX = bound.left + Math.floor(bound.width / 2)
bound.centerY = bound.top + Math.floor(bound.height / 2)
}
enforceCompositeMargins(diagram, compositeBounds)
}
+2 -2
View File
@@ -16,14 +16,14 @@ const STATE_RE = /^state\s+"([^"]+)"\s+as\s+(\S+)$/i
const COMPOSITE_STATE_RE = /^state\s+(?:"([^"]+)"\s+as\s+)?(\S+)\s*\{$/i
const CHOICE_STATE_RE = /^state\s+(\S+)\s+<<choice>>$/i
const TRANSITION_RE = /^(\[\*\]|[^\s:]+)\s*-->\s*(\[\*\]|[^\s:]+)(?:\s*:\s*(.*))?$/
const DIRECTION_RE = /^direction\s+(TB|TD|BT|LR|RL)$/i
const DIRECTION_RE = /^direction\s+(TB|TD|LR|RL)$/i
const NOTE_INLINE_RE = /^note\s+(left|right)\s+of\s+(\S+)\s*:\s*(.*)$/i
const NOTE_START_RE = /^note\s+(left|right)\s+of\s+(\S+)\s*$/i
const NOTE_END_RE = /^end\s+note$/i
function normalizeDirection(value?: string): StateDiagramDirection {
const upper = value?.toUpperCase()
if (upper === "TB" || upper === "TD" || upper === "BT" || upper === "LR" || upper === "RL") return upper
if (upper === "TB" || upper === "TD" || upper === "LR" || upper === "RL") return upper
return DEFAULT_DIRECTION
}
+1 -2
View File
@@ -7,12 +7,11 @@ import type { StateCellStyle } from "./types.js"
export type StateGrid = DiagramCanvas<StateCellStyle>
export function renderStateGridText(grid: StateGrid): string {
return grid.toString({ trimTop: true, trimBottom: true })
return grid.toString({ trimBottom: true })
}
export function renderStateGridStyledText(grid: StateGrid, colors: StateStyleColors): StyledText {
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
trimTop: true,
trimBottom: true,
})
}
+4 -154
View File
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test"
import { diagramTextWidth } from "../core/text.js"
import type { StateDiagramBoxBounds } from "./layout.js"
import { createStateDiagramLayout } from "./layout.js"
import { parseMermaidStateDiagram } from "./parser.js"
@@ -287,7 +286,10 @@ describe("createStateTransitionRenderPlans", () => {
expect(
plan.path.some(
([x, y]) =>
x >= sibling.left && x < sibling.left + sibling.width && y >= sibling.top && y < sibling.top + sibling.height,
x >= sibling.left &&
x < sibling.left + sibling.width &&
y >= sibling.top &&
y < sibling.top + sibling.height,
),
).toBe(false)
})
@@ -346,158 +348,6 @@ describe("createStateTransitionRenderPlans", () => {
}),
).toBe(true)
})
test.each(["LR", "RL", "TB", "TD"] as const)(
"keeps endpoint-disjoint %s transitions on separate cells when a detour exists",
(direction) => {
const diagram = prepareVisibleStateDiagram(
parseMermaidStateDiagram(`stateDiagram-v2
direction ${direction}
state "A" as A
state "B" as B
state "C" as C
state "D" as D
B --> D: e0
C --> A: e1`),
)
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
const first = new Set(plans[0]!.path.map(([x, y]) => `${x}:${y}`))
expect(plans[1]!.path.every(([x, y]) => !first.has(`${x}:${y}`))).toBe(true)
},
)
test("anchors labels to final repaired transition geometry", () => {
const diagram = prepareVisibleStateDiagram(
parseMermaidStateDiagram(`stateDiagram-v2
direction TB
state "A" as A
state "B" as B
state "C" as C
A --> B: e0
A --> C: e1
B --> A: e2`),
)
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const plan = createStateTransitionRenderPlans(diagram, layout.bounds, 30).find(
(candidate) => candidate.route.transition.label === "e2",
)!
const width = Math.max(...plan.label!.lines.map(diagramTextWidth))
const distance = Math.min(
...plan.path.map(([pathX, pathY]) => {
const dx =
pathX < plan.label!.x
? plan.label!.x - pathX
: pathX >= plan.label!.x + width
? pathX - (plan.label!.x + width - 1)
: 0
const dy =
pathY < plan.label!.y
? plan.label!.y - pathY
: pathY >= plan.label!.y + plan.label!.lines.length
? pathY - (plan.label!.y + plan.label!.lines.length - 1)
: 0
return dx + dy
}),
)
expect(plan.pathRepaired).toBe(true)
expect(distance).toBeLessThanOrEqual(4)
})
test.each(["LR", "RL", "TB", "TD"] as const)(
"allocates distinct connected self-transition lanes in %s diagrams",
(direction) => {
for (const count of [2, 3, 4]) {
const diagram = prepareVisibleStateDiagram(
parseMermaidStateDiagram(`stateDiagram-v2
direction ${direction}
${Array.from({ length: count }, (_, index) => ` A --> A: loop-${index}`).join("\n")}`),
)
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
expect(new Set(plans.map((plan) => plan.path.map(([x, y]) => `${x}:${y}`).join("|"))).size).toBe(count)
for (const plan of plans) {
expect(
plan.path.slice(1).every(([x, y], index) => {
const previous = plan.path[index]!
return Math.abs(x - previous[0]) + Math.abs(y - previous[1]) === 1
}),
).toBe(true)
}
}
},
)
test("uses fixed-width side lanes for long parallel vertical labels", () => {
const diagram = prepareVisibleStateDiagram(
parseMermaidStateDiagram(`stateDiagram-v2
direction TB
A --> B: alpha route label that is deliberately long
A --> B: beta route label that is deliberately long
A --> B: gamma route label that is deliberately long`),
)
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const rails = createStateTransitionRoutePlans(diagram, layout.bounds, 30)
.filter((plan) => plan.kind === "side-parallel")
.map((plan) => plan.railX)
expect(rails).toHaveLength(2)
expect(rails[1]! - rails[0]!).toBe(3)
})
test.each([
[
"parallel",
`stateDiagram-v2
direction TB
A --> B: alpha route label that is deliberately long
A --> B: beta route label that is deliberately long
A --> B: gamma route label that is deliberately long`,
3,
],
[
"self",
`stateDiagram-v2
direction LR
A --> A: one
A --> A: two
A --> A: three`,
3,
],
] as const)("keeps %s labels one column clear of frames and route rails", (_, source, count) => {
const diagram = prepareVisibleStateDiagram(parseMermaidStateDiagram(source))
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
const routeCells = plans.flatMap((plan) => plan.path)
expect(plans).toHaveLength(count)
for (const plan of plans) {
const width = Math.max(...plan.label!.lines.map(diagramTextWidth))
for (const [row, line] of plan.label!.lines.entries()) {
const lineWidth = diagramTextWidth(line)
const y = plan.label!.y + row
expect(
routeCells
.filter(([, routeY]) => routeY === y)
.every(([routeX]) => routeX < plan.label!.x - 1 || routeX > plan.label!.x + lineWidth),
).toBe(true)
for (const bound of layout.bounds.values()) {
if (y < bound.top || y >= bound.top + bound.height) continue
expect(bound.left + bound.width <= plan.label!.x - 1 || bound.left >= plan.label!.x + width + 1).toBe(true)
}
}
}
if (source.includes("A --> A")) {
const arrowXs = plans
.flatMap((plan) => plan.cells.filter((cell) => cell.arrowDirection).map((cell) => cell.x))
.sort((left, right) => left - right)
expect(arrowXs.slice(1).every((x, index) => x - arrowXs[index]! >= 4)).toBe(true)
}
})
})
describe("createStateTransitionJunctionPlans", () => {
+51 -291
View File
@@ -1,6 +1,6 @@
import { BorderChars } from "@opentui/core"
import { diagramLineGlyph } from "../core/drawing.js"
import { orthogonalPathPoints, type DiagramDirection } from "../core/geometry.js"
import type { DiagramDirection } from "../core/geometry.js"
import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js"
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
import type { StateDiagramBoxBounds as BoxBounds, StateDiagramNoteBounds } from "./layout.js"
@@ -24,7 +24,7 @@ interface StateTransitionRoutePlanBase {
}
export type StateTransitionRoutePlan =
| (StateTransitionRoutePlanBase & { kind: "self"; lane: number })
| (StateTransitionRoutePlanBase & { kind: "self" })
| (StateTransitionRoutePlanBase & { kind: "horizontal-forward"; leftToRight: boolean })
| (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number; approachX: number })
| (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
@@ -58,7 +58,6 @@ export interface StateTransitionRenderPlan {
cells: readonly StateTransitionRenderCell[]
path: readonly StateTransitionPathPoint[]
label?: StateTransitionRenderLabel
pathRepaired?: boolean
}
export interface StateTransitionRenderOptions {
@@ -351,26 +350,6 @@ function sideParallelTargetApproach(
})
}
function containingCompositeIds(diagram: StateVisibleDiagram, id: string): string[] {
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
const ids: string[] = []
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
while (parentId) {
ids.push(parentId)
parentId = compositesById.get(parentId)?.parentId
}
return ids
}
function innermostCommonComposite(
diagram: StateVisibleDiagram,
transition: StateVisibleTransition,
): string | undefined {
const target = new Set(containingCompositeIds(diagram, transition.to))
return containingCompositeIds(diagram, transition.from).find((id) => target.has(id))
}
export function createStateTransitionRoutePlans(
diagram: StateVisibleDiagram,
bounds: ReadonlyMap<string, BoxBounds>,
@@ -379,13 +358,11 @@ export function createStateTransitionRoutePlans(
): StateTransitionRoutePlan[] {
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const endpointOccurrences = new Map<string, number>()
const selfOccurrences = new Map<string, number>()
const parallelLaneGap = Math.max(
3,
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height + 2),
)
let nextSideRailX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + 3
const sideRailLanes = new Map<string, number>()
const feedbackAllocations = createFeedbackAllocations(diagram, bounds, feedbackLaneY, parallelLaneGap, feedbackTopY)
let nextBottomRailY =
Math.max(
@@ -394,24 +371,9 @@ export function createStateTransitionRoutePlans(
.filter((allocation) => allocation.side === "bottom")
.map((allocation) => allocation.railY),
) + parallelLaneGap
const allocateSideRail = (transition: StateVisibleTransition): number => {
const compositeId = innermostCommonComposite(diagram, transition)
const composite = compositeId ? bounds.get(compositeId) : undefined
if (compositeId && composite) {
const lane = sideRailLanes.get(compositeId) ?? 0
sideRailLanes.set(compositeId, lane + 1)
const descendantRight = Math.max(
composite.left + 1,
...diagram.states.flatMap((state) => {
if (!containingCompositeIds(diagram, state.id).includes(compositeId)) return []
const bound = bounds.get(state.id)
return bound ? [bound.left + bound.width] : []
}),
)
return Math.min(descendantRight + 2 + lane * 3, composite.left + composite.width - 2)
}
const allocateSideRail = (label: string): number => {
const railX = nextSideRailX
nextSideRailX += 3
nextSideRailX += Math.max(3, measureStateTransitionLabel(label).width + 2)
return railX
}
const allocateBottomRail = (): number => {
@@ -430,7 +392,7 @@ export function createStateTransitionRoutePlans(
const targetIsHiddenMarker = isHiddenCompositeMarker(targetState)
const base = { transition, from, to, targetIsChoice, targetIsHiddenMarker }
const sideParallel = (): StateTransitionRoutePlan => {
const railX = allocateSideRail(transition)
const railX = allocateSideRail(transition.label)
return {
...base,
kind: "side-parallel",
@@ -438,11 +400,7 @@ export function createStateTransitionRoutePlans(
targetApproach: sideParallelTargetApproach(diagram, transition, from, to, bounds, railX),
}
}
if (transition.from === transition.to) {
const lane = selfOccurrences.get(transition.from) ?? 0
selfOccurrences.set(transition.from, lane + 1)
return [{ ...base, kind: "self", lane }]
}
if (transition.from === transition.to) return [{ ...base, kind: "self" }]
const endpointKey = `${transition.from}\u0000${transition.to}`
const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
endpointOccurrences.set(endpointKey, parallelIndex + 1)
@@ -491,8 +449,7 @@ export function createStateTransitionRoutePlans(
if (verticalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) {
return [sideParallel()]
}
const verticalFeedback = diagram.direction === "BT" ? from.centerY < to.centerY : from.centerY > to.centerY
if (verticalFeedback) {
if (from.centerY > to.centerY) {
return [sideParallel()]
}
if (from.centerY === to.centerY) {
@@ -655,36 +612,33 @@ function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
}
function addSelfTransition(builder: StateTransitionRenderBuilder): void {
const { from: bounds, transition, lane } = builder.route as Extract<StateTransitionRoutePlan, { kind: "self" }>
const { from: bounds, transition } = builder.route
if (bounds.width <= 1 || bounds.height <= 1) {
const railX = bounds.left + 4 + lane * 4
const railY = bounds.top + 2 + lane * 2
const railX = bounds.left + 4
const railY = bounds.top + 2
addHorizontalLine(builder, bounds.left + 1, railX - 1, bounds.top, 1)
addCell(builder, { x: railX, y: bounds.top, char: "╮" })
addVerticalLine(builder, railX, bounds.top + 1, railY - 1, 1)
addCell(builder, { x: railX, y: bounds.top + 1, char: "│" })
addCell(builder, { x: railX, y: railY, char: "╯" })
addHorizontalLine(builder, railX - 1, bounds.left + 1, railY, -1)
addCell(builder, { x: bounds.left, y: railY, char: "╰" })
for (let y = railY - 1; y > bounds.top + 1; y--) addCell(builder, { x: bounds.left, y, char: "│" })
addCell(builder, { x: bounds.left, y: bounds.top + 1, arrowDirection: "up" })
addPathPoint(builder, bounds.left, bounds.top)
if (transition.label) addLabel(builder, railX + 2, lane === 0 ? bounds.top + 1 : railY - 1, transition.label)
if (transition.label) addLabel(builder, railX + 2, bounds.top + 1, transition.label)
return
}
const sourceX = bounds.left + Math.max(2, Math.floor(bounds.width / 3))
const bottomY = bounds.top + bounds.height - 1
const railY = bottomY + 2 + lane * 3
const targetX =
Math.max(sourceX + 3, bounds.left + Math.min(bounds.width - 3, Math.ceil((bounds.width * 2) / 3))) + lane * 4
const railY = bottomY + 2
const targetX = Math.max(sourceX + 3, bounds.left + Math.min(bounds.width - 3, Math.ceil((bounds.width * 2) / 3)))
addBottomDeparture(builder, bounds, sourceX)
addVerticalLine(builder, sourceX, bottomY + 1, railY - 1, 1)
addCell(builder, { x: sourceX, y: bottomY + 1, char: "│" })
addCell(builder, { x: sourceX, y: railY, char: "╰" })
for (let x = sourceX + 1; x < targetX; x++) addCell(builder, { x, y: railY, char: "─" })
addCell(builder, { x: targetX, y: railY, char: "╯" })
for (let y = railY - 1; y > bottomY + 1; y--) addCell(builder, { x: targetX, y, char: "│" })
addCell(builder, { x: targetX, y: bottomY + 1, arrowDirection: "up" })
if (transition.label) addLabel(builder, targetX + 2, lane === 0 ? bottomY + 1 : railY - 1, transition.label)
if (transition.label) addLabel(builder, targetX + 2, bottomY + 1, transition.label)
}
function outsideBottomY(bounds: BoxBounds): number {
@@ -778,8 +732,10 @@ function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
}
function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX, targetApproach } =
builder.route as Extract<StateTransitionRoutePlan, { kind: "side-parallel" }>
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX, targetApproach } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "side-parallel" }
>
const startX = from.left + from.width
const startY = from.centerY
const endY = targetApproach === "top" ? to.top - 2 : targetApproach === "bottom" ? to.top + to.height + 1 : to.centerY
@@ -801,12 +757,7 @@ function addSideParallelTransition(builder: StateTransitionRenderBuilder): void
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
if (transition.label) {
const metrics = measureStateTransitionLabel(transition.label)
addLabel(
builder,
railX + 2,
Math.max(0, Math.floor((startY + to.centerY - metrics.height + 1) / 2)),
transition.label,
)
addLabel(builder, railX + 2, Math.max(0, Math.floor((startY + to.centerY - metrics.height + 1) / 2)), transition.label)
}
return
}
@@ -987,62 +938,31 @@ function routeIntersectsUnrelatedState(
"boundary",
stateDiagramNoteConnector(noteBound, target).points,
)
return plan.path.some(([x, y]) => connector.spans.some((span) => span.y === y && x >= span.fromX && x <= span.toX))
return plan.path.some(([x, y]) =>
connector.spans.some((span) => span.y === y && x >= span.fromX && x <= span.toX),
)
})
}
function findBodySafePath(
start: StateTransitionPathPoint,
end: StateTransitionPathPoint,
diagram: StateVisibleDiagram,
bounds: ReadonlyMap<string, BoxBounds>,
plan: StateTransitionRenderPlan,
search: StateSearchSpace,
budget: StateSearchBudget,
): StateTransitionPathPoint[] | undefined {
const margin = Math.max(8, bounds.size * 2)
const compositeId = innermostCommonComposite(diagram, plan.route.transition)
const composite = compositeId ? bounds.get(compositeId) : undefined
const searchBounds = {
minX: composite ? composite.left + 1 : search.minX - margin,
minY: composite ? composite.top + 1 : Math.min(search.minY, ...plan.path.map((point) => point[1])) - margin,
maxX: composite
? composite.left + composite.width - 2
: Math.max(search.maxX, ...plan.path.map((point) => point[0])) + margin,
maxY: composite
? composite.top + composite.height - 2
: Math.max(search.maxY, ...plan.path.map((point) => point[1])) + margin,
}
const isFree = ([x, y]: StateTransitionPathPoint) =>
x >= searchBounds.minX &&
x <= searchBounds.maxX &&
y >= searchBounds.minY &&
y <= searchBounds.maxY &&
!search.blocked.has(`${x}:${y}`)
const pathMinX = Math.min(...plan.path.map((point) => point[0]))
const pathMinY = Math.min(...plan.path.map((point) => point[1]))
const pathMaxX = Math.max(...plan.path.map((point) => point[0]))
const pathMaxY = Math.max(...plan.path.map((point) => point[1]))
const directCandidates: StateTransitionPathPoint[][] = [
[start, [start[0], end[1]] as const, end],
[start, [end[0], start[1]] as const, end],
...Array.from({ length: 4 }, (_, index) => index + 1).flatMap((offset): StateTransitionPathPoint[][] => [
[start, [start[0], pathMinY - offset], [end[0], pathMinY - offset], end],
[start, [start[0], pathMaxY + offset], [end[0], pathMaxY + offset], end],
[start, [pathMinX - offset, start[1]], [pathMinX - offset, end[1]], end],
[start, [pathMaxX + offset, start[1]], [pathMaxX + offset, end[1]], end],
]),
]
const direct = directCandidates
.map((points) => orthogonalPathPoints(points.map(([x, y]) => ({ x, y }))).map(({ x, y }) => [x, y] as const))
.filter((points) => points.every(isFree))
.sort((left, right) => left.length - right.length)[0]
if (direct) return direct
const path = findStateManhattanPath(
[{ x: start[0], y: start[1] }],
{ x: end[0], y: end[1] },
search,
searchBounds,
{
minX: search.minX - margin,
minY: Math.min(search.minY, ...plan.path.map((point) => point[1])) - margin,
maxX: Math.max(search.maxX, ...plan.path.map((point) => point[0])) + margin,
maxY: Math.max(search.maxY, ...plan.path.map((point) => point[1])) + margin,
},
budget,
)
return path?.map((point) => [point.x, point.y] as const)
@@ -1055,29 +975,17 @@ function bodySafeTransitionPlan(
noteBounds: readonly StateDiagramNoteBounds[],
search: StateSearchSpace,
budget: StateSearchBudget,
forceRepair = false,
): StateTransitionRenderPlan {
if (!forceRepair && !routeIntersectsUnrelatedState(plan, diagram, bounds, noteBounds)) return plan
if (!routeIntersectsUnrelatedState(plan, diagram, bounds, noteBounds)) return plan
const sourceOutsideIndex = plan.path.findIndex((point) => !pointIsInsideBounds(point, plan.route.from))
const targetOutsideIndex = plan.path.findLastIndex((point) => !pointIsInsideBounds(point, plan.route.to))
if (sourceOutsideIndex < 0 || targetOutsideIndex < sourceOutsideIndex) return plan
const safePath = findBodySafePath(
plan.path[sourceOutsideIndex]!,
plan.path[targetOutsideIndex]!,
diagram,
bounds,
plan,
search,
budget,
)
const safePath = findBodySafePath(plan.path[sourceOutsideIndex]!, plan.path[targetOutsideIndex]!, bounds, plan, search, budget)
if (!safePath) return alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget)
const prefix = plan.path.slice(0, sourceOutsideIndex)
const suffix = plan.path.slice(targetOutsideIndex + 1)
const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix)
if (safePath.length <= plan.path.length + 4) return repaired
const alternate = alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget)
return alternate !== plan && alternate.path.length < repaired.path.length ? alternate : repaired
return renderBodySafeTransitionPlan(plan, safePath, prefix, suffix)
}
function alternateBodySafeTransitionPlan(
@@ -1088,10 +996,9 @@ function alternateBodySafeTransitionPlan(
search: StateSearchSpace,
budget: StateSearchBudget,
): StateTransitionRenderPlan {
const candidates: StateTransitionRenderPlan[] = []
for (const source of stateRoutePorts(plan.route.from)) {
for (const target of stateRoutePorts(plan.route.to)) {
const safePath = findBodySafePath(source.outside, target.outside, diagram, bounds, plan, search, budget)
const safePath = findBodySafePath(source.outside, target.outside, bounds, plan, search, budget)
if (!safePath) continue
const prefix = plan.route.from.width > 1 && plan.route.from.height > 1 ? [source.border] : []
const suffix =
@@ -1099,10 +1006,10 @@ function alternateBodySafeTransitionPlan(
? ([[plan.route.to.left, plan.route.to.top]] as const)
: []
const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix, source.char)
if (!routeIntersectsUnrelatedState(repaired, diagram, bounds, noteBounds)) candidates.push(repaired)
if (!routeIntersectsUnrelatedState(repaired, diagram, bounds, noteBounds)) return repaired
}
}
return candidates.sort((left, right) => left.path.length - right.path.length)[0] ?? plan
return plan
}
function stateRoutePorts(bounds: BoxBounds): Array<{
@@ -1165,50 +1072,7 @@ function renderBodySafeTransitionPlan(
cells.push({ x: point[0], y: point[1], char: diagramLineGlyph(connections, "rounded") })
}
return { ...plan, cells, path: fullPath, pathRepaired: true }
}
function labelDistanceToPath(
x: number,
y: number,
width: number,
height: number,
path: readonly StateTransitionPathPoint[],
): number {
return Math.min(
...path.map(([pathX, pathY]) => {
const dx = pathX < x ? x - pathX : pathX >= x + width ? pathX - (x + width - 1) : 0
const dy = pathY < y ? y - pathY : pathY >= y + height ? pathY - (y + height - 1) : 0
return dx + dy
}),
)
}
function stateTransitionLabelCandidates(
plan: StateTransitionRenderPlan,
width: number,
height: number,
): Array<{ x: number; y: number }> {
const candidates = new Map<string, { x: number; y: number }>()
const add = (x: number, y: number) => candidates.set(`${x}:${y}`, { x, y })
if (
plan.label &&
(!plan.pathRepaired || labelDistanceToPath(plan.label.x, plan.label.y, width, height, plan.path) <= 4)
) {
add(plan.label.x, plan.label.y)
}
for (const [x, y] of plan.path) {
add(x + 2, y - Math.floor(height / 2))
add(x - width - 2, y - Math.floor(height / 2))
add(x - Math.floor(width / 2), y - height - 1)
add(x - Math.floor(width / 2), y + 2)
}
const preferred = plan.label ?? { x: plan.path[0]?.[0] ?? 0, y: plan.path[0]?.[1] ?? 0 }
return [...candidates.values()].sort((left, right) => {
const leftDistance = Math.abs(left.x - preferred.x) + Math.abs(left.y - preferred.y)
const rightDistance = Math.abs(right.x - preferred.x) + Math.abs(right.y - preferred.y)
return leftDistance - rightDistance
})
return { ...plan, cells, path: fullPath }
}
function placeStateTransitionLabels(
@@ -1232,19 +1096,6 @@ function placeStateTransitionLabels(
plan.path.map(([x, y]) => ({ x, y })),
),
),
...diagram.composites.flatMap((composite) => {
const bound = bounds.get(composite.id)
if (!bound) return []
return [
spatialPathClaim(`composite:${composite.id}`, `composite:${composite.id}`, "boundary", [
{ x: bound.left, y: bound.top },
{ x: bound.left + bound.width - 1, y: bound.top },
{ x: bound.left + bound.width - 1, y: bound.top + bound.height - 1 },
{ x: bound.left, y: bound.top + bound.height - 1 },
{ x: bound.left, y: bound.top },
]),
]
}),
...noteBounds.flatMap((noteBound) => {
const target = bounds.get(noteBound.note.target)
return [
@@ -1263,25 +1114,10 @@ function placeStateTransitionLabels(
}),
)
const placed = new Map<number, StateTransitionRenderPlan>()
const endpointCounts = new Map<string, number>()
for (const plan of plans) {
const key = `${plan.route.transition.from}\u0000${plan.route.transition.to}`
endpointCounts.set(key, (endpointCounts.get(key) ?? 0) + 1)
}
const placementOrder = [...plans.keys()].sort(
(left, right) => Number(Boolean(plans[left]!.pathRepaired)) - Number(Boolean(plans[right]!.pathRepaired)),
)
for (const planIndex of placementOrder) {
const plan = plans[planIndex]!
if (!plan.label) {
placed.set(planIndex, plan)
continue
}
return plans.map((plan, planIndex) => {
if (!plan.label) return plan
const width = Math.max(...plan.label.lines.map(diagramTextWidth))
const endpointKey = `${plan.route.transition.from}\u0000${plan.route.transition.to}`
const needsLaneClearance = (endpointCounts.get(endpointKey) ?? 0) > 1
const statePadding = needsLaneClearance || plan.label.lines.length > 1 ? 1 : 0
const statePadding = plan.label.lines.length === 1 ? 0 : 1
const labelClaim = (x: number, y: number) =>
spatialRectClaim(`label:${planIndex}`, `label:${planIndex}`, "label", {
left: x,
@@ -1295,35 +1131,19 @@ function placeStateTransitionLabels(
clearance: {
body: statePadding,
label: { x: 1, y: 0 },
route:
plan.pathRepaired || plan.route.kind === "side-parallel" || needsLaneClearance
? {
x: 1,
y: 0,
}
: 0,
},
})
}
const candidates = stateTransitionLabelCandidates(plan, width, plan.label.lines.length)
const nearby = candidates.find(
(candidate) =>
(!(plan.pathRepaired || plan.route.kind === "side-parallel" || needsLaneClearance) ||
labelDistanceToPath(candidate.x, candidate.y, width, plan.label!.lines.length, plan.path) >= 2) &&
isClear(candidate.x, candidate.y),
)
let x = nearby?.x ?? candidates[0]?.x ?? plan.label.x
let y = nearby?.y ?? candidates[0]?.y ?? plan.label.y
if (!nearby) {
let x = plan.label.x
let y = plan.label.y
if (!isClear(x, y)) {
search: for (let distance = 1; distance < 500; distance++) {
for (let dx = -distance; dx <= distance; dx++) {
const dy = distance - Math.abs(dx)
for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
const candidateX = x + dx
if (!isClear(candidateX, candidateY)) continue
const pathDistance = labelDistanceToPath(candidateX, candidateY, width, plan.label!.lines.length, plan.path)
if (pathDistance < 2 || pathDistance > 8) continue
x = candidateX
y = candidateY
break search
@@ -1331,26 +1151,10 @@ function placeStateTransitionLabels(
}
}
}
if (!isClear(x, y)) {
fallback: for (let distance = 1; distance < 500; distance++) {
for (let dx = -distance; dx <= distance; dx++) {
const dy = distance - Math.abs(dx)
for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
const candidateX = x + dx
if (!isClear(candidateX, candidateY)) continue
x = candidateX
y = candidateY
break fallback
}
}
}
}
if (!isClear(x, y)) throw new Error(`Transition ${endpointKey} has no clear label position`)
space = space.add(labelClaim(x, y))
placed.set(planIndex, { ...plan, label: { ...plan.label, x, y } })
}
return plans.map((plan, index) => placed.get(index) ?? plan)
return { ...plan, label: { ...plan.label, x, y } }
})
}
export function createStateTransitionRenderPlans(
@@ -1365,59 +1169,15 @@ export function createStateTransitionRenderPlans(
createStateTransitionRenderPlan,
)
if (options.repairRoutes === false) return placeStateTransitionLabels(plans, diagram, bounds, noteBounds)
const baseObstacles = transitionObstacles(diagram, bounds, noteBounds)
const repaired: StateTransitionRenderPlan[] = []
for (const [index, plan] of plans.entries()) {
const disjoint = repaired.filter((previous) => transitionsHaveDisjointEndpoints(previous, plan))
const routeObstacles = repaired.filter(
(previous) => disjoint.includes(previous) || transitionsAreReciprocal(previous, plan),
)
const routeSpace = createStateSearchSpace(
baseObstacles.add(
...routeObstacles.map((previous, previousIndex) =>
spatialPathClaim(
`transition:${index}:obstacle:${previousIndex}`,
`transition:${index}:obstacle:${previousIndex}`,
"route",
previous.path.map(([x, y]) => ({ x, y })),
),
),
),
)
repaired.push(
bodySafeTransitionPlan(
plan,
diagram,
bounds,
noteBounds,
routeSpace,
budget,
disjoint.some((previous) => pathsIntersect(previous.path, plan.path)),
),
)
}
return placeStateTransitionLabels(repaired, diagram, bounds, noteBounds)
}
function transitionsHaveDisjointEndpoints(left: StateTransitionRenderPlan, right: StateTransitionRenderPlan): boolean {
const leftEndpoints = new Set([left.route.transition.from, left.route.transition.to])
return !leftEndpoints.has(right.route.transition.from) && !leftEndpoints.has(right.route.transition.to)
}
function transitionsAreReciprocal(left: StateTransitionRenderPlan, right: StateTransitionRenderPlan): boolean {
return (
left.route.transition.from === right.route.transition.to && left.route.transition.to === right.route.transition.from
const routeSpace = createStateSearchSpace(transitionObstacles(diagram, bounds, noteBounds))
return placeStateTransitionLabels(
plans.map((plan) => bodySafeTransitionPlan(plan, diagram, bounds, noteBounds, routeSpace, budget)),
diagram,
bounds,
noteBounds,
)
}
function pathsIntersect(
left: readonly StateTransitionPathPoint[],
right: readonly StateTransitionPathPoint[],
): boolean {
const occupied = new Set(left.map(([x, y]) => `${x}:${y}`))
return right.some(([x, y]) => occupied.has(`${x}:${y}`))
}
function transitionObstacles(
diagram: StateVisibleDiagram,
bounds: ReadonlyMap<string, BoxBounds>,
+1 -3
View File
@@ -1,6 +1,6 @@
import type { BorderStyle } from "@opentui/core"
export type StateDiagramDirection = "TB" | "TD" | "BT" | "LR" | "RL"
export type StateDiagramDirection = "TB" | "TD" | "LR" | "RL"
export type StateDiagramArrowHeadStyle = "filled" | "line"
export interface StateDiagramState {
@@ -41,8 +41,6 @@ export interface StateDiagramRenderOptions {
borderStyle?: BorderStyle
arrowHeadStyle?: StateDiagramArrowHeadStyle
minStateGap?: number
/** Target rendered width. Oversized horizontal layouts fold vertically. */
layoutMaxWidth?: number
}
export type NoteConnectorRampStyle = `noteConnectorRamp${1 | 2 | 3}`
@@ -1,493 +0,0 @@
import type { FlowchartDirection } from "../../flowchart/types.js"
import type { StateDiagramDirection } from "../../state/types.js"
export type LayoutFixture = {
id: string
kind: "flowchart" | "state"
family: string
profile: LabelProfile
source: string
curated?: boolean
}
type LabelProfile = "short" | "long" | "unicode"
const flowVariants = [
["TB", "short"],
["TD", "long"],
["BT", "unicode"],
["LR", "short"],
["RL", "long"],
["TB", "unicode"],
["TD", "short"],
["BT", "long"],
["LR", "unicode"],
["RL", "short"],
["LR", "long"],
["TD", "unicode"],
["TB", "long"],
["BT", "short"],
["RL", "unicode"],
] as const satisfies readonly (readonly [FlowchartDirection, LabelProfile])[]
const stateVariants = [
["TB", "short"],
["TD", "long"],
["LR", "unicode"],
["RL", "short"],
["TB", "long"],
["TD", "unicode"],
["LR", "short"],
["RL", "long"],
["LR", "long"],
["TD", "short"],
["TB", "unicode"],
["RL", "unicode"],
["BT", "short"],
["BT", "long"],
["BT", "unicode"],
] as const satisfies readonly (readonly [StateDiagramDirection, LabelProfile])[]
function nodeLabel(id: string, profile: LabelProfile): string {
if (profile === "long") return `${id} deliberate deployment stage with a long descriptive label`
if (profile === "unicode") return `${id} 東京<br/>résumé 🚀`
return `${id} node`
}
function edgeLabel(id: string, profile: LabelProfile): string {
if (profile === "long") return `${id} transition carrying detailed deployment context`
if (profile === "unicode") return `${id} 東京<br/>✓ prêt`
return `${id} edge`
}
function flowNode(id: string, profile: LabelProfile): string {
return ` ${id}["${nodeLabel(id, profile)}"]`
}
function flowEdge(from: string, to: string, id: string, profile: LabelProfile): string {
return ` ${from} -->|"${edgeLabel(id, profile)}"| ${to}`
}
function flowSource(
direction: FlowchartDirection,
profile: LabelProfile,
nodes: readonly string[],
edges: readonly [from: string, to: string, id: string][],
extra: readonly string[] = [],
): string {
return [
`flowchart ${direction}`,
...nodes.map((id) => flowNode(id, profile)),
...extra,
...edges.map(([from, to, id]) => flowEdge(from, to, id, profile)),
].join("\n")
}
const flowFamilies = {
chain(direction: FlowchartDirection, profile: LabelProfile) {
return flowSource(
direction,
profile,
["A", "B", "C", "D", "E", "F"],
[
["A", "B", "E01"],
["B", "C", "E02"],
["C", "D", "E03"],
["D", "E", "E04"],
["E", "F", "E05"],
],
)
},
fork(direction: FlowchartDirection, profile: LabelProfile) {
return flowSource(
direction,
profile,
["A", "B", "C", "D", "E"],
[
["A", "B", "E01"],
["A", "C", "E02"],
["A", "D", "E03"],
["A", "E", "E04"],
],
)
},
join(direction: FlowchartDirection, profile: LabelProfile) {
return flowSource(
direction,
profile,
["A", "B", "C", "D", "E"],
[
["A", "E", "E01"],
["B", "E", "E02"],
["C", "E", "E03"],
["D", "E", "E04"],
],
)
},
cycle(direction: FlowchartDirection, profile: LabelProfile) {
return flowSource(
direction,
profile,
["A", "B", "C", "D"],
[
["A", "B", "E01"],
["B", "C", "E02"],
["C", "D", "E03"],
["D", "A", "E04"],
["C", "A", "E05"],
],
)
},
crossing(direction: FlowchartDirection, profile: LabelProfile) {
return flowSource(
direction,
profile,
["A", "B", "C", "D", "E", "F"],
[
["A", "C", "E01"],
["A", "D", "E02"],
["B", "C", "E03"],
["B", "D", "E04"],
["C", "E", "E05"],
["D", "F", "E06"],
],
)
},
parallel(direction: FlowchartDirection, profile: LabelProfile) {
return flowSource(
direction,
profile,
["A", "B", "C"],
[
["A", "B", "E01"],
["A", "B", "E02"],
["A", "B", "E03"],
["B", "C", "E04"],
["B", "C", "E05"],
],
)
},
self(direction: FlowchartDirection, profile: LabelProfile) {
return flowSource(
direction,
profile,
["A", "B", "C"],
[
["A", "A", "E01"],
["A", "B", "E02"],
["B", "B", "E03"],
["B", "C", "E04"],
],
)
},
subgraph(direction: FlowchartDirection, profile: LabelProfile) {
return [
`flowchart ${direction}`,
` subgraph Left["Left ${nodeLabel("SG1", profile)}"]`,
flowNode("A", profile),
flowNode("B", profile),
" end",
` subgraph Right["Right ${nodeLabel("SG2", profile)}"]`,
flowNode("C", profile),
flowNode("D", profile),
" end",
flowEdge("A", "B", "E01", profile),
flowEdge("A", "C", "E02", profile),
flowEdge("B", "D", "E03", profile),
flowEdge("C", "D", "E04", profile),
].join("\n")
},
"nested-subgraph"(direction: FlowchartDirection, profile: LabelProfile) {
const local = direction === "LR" || direction === "RL" ? "TB" : "LR"
return [
`flowchart ${direction}`,
` subgraph Outer["Outer ${nodeLabel("SG1", profile)}"]`,
` direction ${local}`,
` subgraph Inner["Inner ${nodeLabel("SG2", profile)}"]`,
flowNode("A", profile),
flowNode("B", profile),
" end",
flowNode("C", profile),
" end",
flowNode("D", profile),
flowEdge("A", "B", "E01", profile),
flowEdge("A", "C", "E02", profile),
flowEdge("B", "D", "E03", profile),
flowEdge("C", "D", "E04", profile),
].join("\n")
},
} satisfies Record<string, (direction: FlowchartDirection, profile: LabelProfile) => string>
function stateDeclaration(id: string, profile: LabelProfile, indent = " "): string {
return `${indent}state "${nodeLabel(id, profile)}" as ${id}`
}
function stateTransition(from: string, to: string, id: string, profile: LabelProfile, indent = " "): string {
return `${indent}${from} --> ${to}: ${edgeLabel(id, profile)}`
}
function stateSource(
direction: StateDiagramDirection,
profile: LabelProfile,
states: readonly string[],
transitions: readonly [from: string, to: string, id: string][],
extra: readonly string[] = [],
): string {
return [
"stateDiagram-v2",
` direction ${direction}`,
...states.map((id) => stateDeclaration(id, profile)),
...extra,
...transitions.map(([from, to, id]) => stateTransition(from, to, id, profile)),
].join("\n")
}
const stateFamilies = {
chain(direction: StateDiagramDirection, profile: LabelProfile) {
return stateSource(
direction,
profile,
["A", "B", "C", "D", "E"],
[
["A", "B", "E01"],
["B", "C", "E02"],
["C", "D", "E03"],
["D", "E", "E04"],
],
[" [*] --> A", " E --> [*]"],
)
},
fork(direction: StateDiagramDirection, profile: LabelProfile) {
return stateSource(
direction,
profile,
["A", "B", "C", "D"],
[
["A", "B", "E01"],
["A", "C", "E02"],
["A", "D", "E03"],
],
)
},
join(direction: StateDiagramDirection, profile: LabelProfile) {
return stateSource(
direction,
profile,
["A", "B", "C", "D"],
[
["A", "D", "E01"],
["B", "D", "E02"],
["C", "D", "E03"],
],
)
},
cycle(direction: StateDiagramDirection, profile: LabelProfile) {
return stateSource(
direction,
profile,
["A", "B", "C", "D"],
[
["A", "B", "E01"],
["B", "C", "E02"],
["C", "D", "E03"],
["D", "A", "E04"],
["C", "A", "E05"],
],
)
},
crossing(direction: StateDiagramDirection, profile: LabelProfile) {
return stateSource(
direction,
profile,
["A", "B", "C", "D"],
[
["A", "C", "E01"],
["A", "D", "E02"],
["B", "C", "E03"],
["B", "D", "E04"],
],
)
},
parallel(direction: StateDiagramDirection, profile: LabelProfile) {
return stateSource(
direction,
profile,
["A", "B", "C"],
[
["A", "B", "E01"],
["A", "B", "E02"],
["A", "B", "E03"],
["B", "C", "E04"],
],
)
},
self(direction: StateDiagramDirection, profile: LabelProfile) {
return stateSource(
direction,
profile,
["A", "B", "C"],
[
["A", "A", "E01"],
["A", "B", "E02"],
["B", "B", "E03"],
["B", "C", "E04"],
],
)
},
choice(direction: StateDiagramDirection, profile: LabelProfile) {
return stateSource(
direction,
profile,
["A", "B", "C"],
[
["A", "Choice", "E01"],
["Choice", "B", "E02"],
["Choice", "C", "E03"],
["C", "A", "E04"],
],
[" state Choice <<choice>>"],
)
},
notes(direction: StateDiagramDirection, profile: LabelProfile) {
return stateSource(
direction,
profile,
["A", "B", "C"],
[
["A", "B", "E01"],
["B", "C", "E02"],
["C", "A", "E03"],
],
[
` note left of A: ${edgeLabel("N01", profile)}`,
` note right of B: ${edgeLabel("N02", profile)}`,
` note right of C: ${edgeLabel("N03", profile)}`,
],
)
},
composite(direction: StateDiagramDirection, profile: LabelProfile) {
return [
"stateDiagram-v2",
` direction ${direction}`,
` state "${nodeLabel("Outer", profile)}" as Outer {`,
stateDeclaration("A", profile, " "),
stateDeclaration("B", profile, " "),
" [*] --> A",
stateTransition("A", "B", "E01", profile, " "),
" B --> [*]",
" }",
stateDeclaration("Done", profile),
stateTransition("Outer", "Done", "E02", profile),
` note right of B: ${edgeLabel("N01", profile)}`,
].join("\n")
},
"nested-composite"(direction: StateDiagramDirection, profile: LabelProfile) {
return [
"stateDiagram-v2",
` direction ${direction}`,
` state "${nodeLabel("Session", profile)}" as Session {`,
" [*] --> Open",
` state "${nodeLabel("Open", profile)}" as Open {`,
stateDeclaration("Clean", profile, " "),
stateDeclaration("Dirty", profile, " "),
" [*] --> Clean",
stateTransition("Clean", "Dirty", "E01", profile, " "),
stateTransition("Dirty", "Clean", "E02", profile, " "),
" Dirty --> [*]",
" }",
" Open --> [*]",
" }",
stateDeclaration("Done", profile),
stateTransition("Session", "Done", "E03", profile),
` note right of Dirty: ${edgeLabel("N01", profile)}`,
].join("\n")
},
} satisfies Record<string, (direction: StateDiagramDirection, profile: LabelProfile) => string>
export const deploymentArchitectureSource = `flowchart LR
Client[OpenCode client]
subgraph CF[Cloudflare]
DNS[opencode.ai]
Web[Console frontend Worker]
Proxy[Console API proxy Worker]
Infer[inference-next Worker]
KV[Model registry KV]
Redis[Upstash Redis]
Logs[Axiom / Cloudflare logs]
Lake[Pipeline to R2 data lake]
end
subgraph AWS[AWS]
EKS[EKS cluster]
API[Console API pod<br/>1 replica]
OTEL[OTel collector]
ECR[ECR]
end
DB[(PlanetScale)]
Models[Anthropic / OpenAI / other providers]
Client -->|/inference/*| DNS --> Infer
Client -->|/console/*| DNS --> Web
Web -->|/console/api, /auth, etc.| Proxy
Proxy -->|Cloudflare VPC service| API
Infer -->|public DATABASE_URL| DB
Infer --> KV
Infer --> Redis
Infer --> Models
Infer --> Logs
Infer --> Lake
API -->|private DATABASE_AWS_URL| DB
API --> OTEL
ECR --> API`
export function layoutFixtures(): readonly LayoutFixture[] {
const flowcharts = Object.entries(flowFamilies).flatMap(([family, source]) =>
flowVariants.map(([direction, profile]) => ({
id: `flowchart/${family}/${direction.toLowerCase()}-${profile}`,
kind: "flowchart" as const,
family,
profile,
source: source(direction, profile),
})),
)
const states = Object.entries(stateFamilies).flatMap(([family, source]) =>
stateVariants.map(([direction, profile]) => ({
id: `state/${family}/${direction.toLowerCase()}-${profile}`,
kind: "state" as const,
family,
profile,
source: source(direction, profile),
})),
)
return [
...flowcharts,
{
id: "flowchart/deployment-architecture/curated",
kind: "flowchart" as const,
family: "deployment-architecture",
profile: "short" as const,
source: deploymentArchitectureSource,
curated: true,
},
{
id: "flowchart/grouped-fanout/curated",
kind: "flowchart" as const,
family: "grouped-fanout",
profile: "short" as const,
source: `flowchart TD
subgraph Group
S[Source]
S -->|route 0 detail| N0[Node 0]
S -->|route 1 detail| N1[Node 1]
S -->|route 2 detail| N2[Node 2]
S -->|route 3 detail| N3[Node 3]
end`,
curated: true,
},
...states,
]
}
@@ -1,521 +0,0 @@
import { orthogonalPathPoints, segmentBetween, type DiagramPoint } from "../../core/geometry.js"
import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../../core/spatial.js"
import { diagramTextWidth } from "../../core/text.js"
import { splitDiagramLines } from "../../core/text-lines.js"
import { drawFlowchartDiagramGrid } from "../../flowchart/drawing.js"
import { layoutFlowchartDiagram } from "../../flowchart/layout.js"
import { flowchartRouteLabelLayout } from "../../flowchart/labels.js"
import { parseMermaidFlowchartDiagram } from "../../flowchart/parser.js"
import { createStateDiagramDrawing } from "../../state/drawing.js"
import type { StateDiagramBoxBounds } from "../../state/layout.js"
import { stateDiagramNoteConnector } from "../../state/note.js"
import { parseMermaidStateDiagram } from "../../state/parser.js"
import type { StateTransitionRenderPlan } from "../../state/routing.js"
import { isHiddenCompositeMarker } from "../../state/visible-model.js"
import { layoutFixtures, type LayoutFixture } from "./fixtures.js"
export const auditViewports = [60, 80, 120] as const
export type LayoutMetrics = {
width: number
height: number
area: number
routeLength: number
bends: number
crossings: number
sharedRouteCells: number
overflow: number
}
export type LayoutAudit = {
fixture: LayoutFixture
viewport: (typeof auditViewports)[number]
output: string
metrics: LayoutMetrics
violations: string[]
}
type Bounds = Pick<StateDiagramBoxBounds, "left" | "top" | "width" | "height">
type AuditedRoute = {
from: string
to: string
points: readonly DiagramPoint[]
}
function finiteBounds(bounds: Bounds): boolean {
return (
[bounds.left, bounds.top, bounds.width, bounds.height].every(Number.isFinite) &&
bounds.width > 0 &&
bounds.height > 0
)
}
function boundsOverlap(left: Bounds, right: Bounds): boolean {
return (
left.left < right.left + right.width &&
left.left + left.width > right.left &&
left.top < right.top + right.height &&
left.top + left.height > right.top
)
}
function boundsContain(outer: Bounds, inner: Bounds): boolean {
return (
inner.left >= outer.left &&
inner.top >= outer.top &&
inner.left + inner.width <= outer.left + outer.width &&
inner.top + inner.height <= outer.top + outer.height
)
}
function pointInBounds(point: DiagramPoint, bounds: Bounds): boolean {
return (
point.x >= bounds.left &&
point.x < bounds.left + bounds.width &&
point.y >= bounds.top &&
point.y < bounds.top + bounds.height
)
}
function pointTouchesBounds(point: DiagramPoint, bounds: Bounds): boolean {
if (pointInBounds(point, bounds)) return true
return (
((point.x === bounds.left - 1 || point.x === bounds.left + bounds.width) &&
point.y >= bounds.top &&
point.y < bounds.top + bounds.height) ||
((point.y === bounds.top - 1 || point.y === bounds.top + bounds.height) &&
point.x >= bounds.left &&
point.x < bounds.left + bounds.width)
)
}
function isOrthogonal(points: readonly DiagramPoint[]): boolean {
return points.every((point, index) => {
if (
!Number.isFinite(point.x) ||
!Number.isFinite(point.y) ||
!Number.isInteger(point.x) ||
!Number.isInteger(point.y)
)
return false
const previous = points[index - 1]
return !previous || previous.x === point.x || previous.y === point.y
})
}
function expandedPath(points: readonly DiagramPoint[]): DiagramPoint[] {
if (!isOrthogonal(points)) return []
return orthogonalPathPoints(points)
}
function routeLength(points: readonly DiagramPoint[]): number {
return routeSegments(points).reduce((total, segment) => total + segment.length, 0)
}
function routeBends(points: readonly DiagramPoint[]): number {
const directions = points.slice(1).flatMap((point, index) => {
const previous = points[index]
if (point.x === previous.x && point.y !== previous.y) return ["y"]
if (point.y === previous.y && point.x !== previous.x) return ["x"]
return []
})
return directions.slice(1).filter((axis, index) => axis !== directions[index]).length
}
function routeSegments(points: readonly DiagramPoint[]) {
return points.slice(1).flatMap((point, index) => segmentBetween(points[index]!, point) ?? [])
}
function crossingCount(routes: readonly AuditedRoute[]): number {
let count = 0
for (const [index, route] of routes.entries()) {
for (const other of routes.slice(index + 1)) {
for (const segment of routeSegments(route.points)) {
for (const otherSegment of routeSegments(other.points)) {
if (segment.axis === otherSegment.axis) continue
const horizontal = segment.axis === "x" ? segment : otherSegment
const vertical = segment.axis === "y" ? segment : otherSegment
const x = vertical.from.x
const y = horizontal.from.y
const horizontalMin = Math.min(horizontal.from.x, horizontal.to.x)
const horizontalMax = Math.max(horizontal.from.x, horizontal.to.x)
const verticalMin = Math.min(vertical.from.y, vertical.to.y)
const verticalMax = Math.max(vertical.from.y, vertical.to.y)
if (x <= horizontalMin || x >= horizontalMax || y <= verticalMin || y >= verticalMax) continue
count++
}
}
}
}
return count
}
function sharedRouteCellCount(routes: readonly AuditedRoute[]): number {
let count = 0
const cells = routes.map((route) => new Set(expandedPath(route.points).map((point) => `${point.x}:${point.y}`)))
for (const [index, routeCells] of cells.entries()) {
for (const other of cells.slice(index + 1)) {
for (const cell of routeCells) if (other.has(cell)) count++
}
}
return count
}
function metrics(
width: number,
height: number,
routes: readonly AuditedRoute[],
viewport: (typeof auditViewports)[number],
): LayoutMetrics {
return {
width,
height,
area: width * height,
routeLength: routes.reduce((total, route) => total + routeLength(route.points), 0),
bends: routes.reduce((total, route) => total + routeBends(route.points), 0),
crossings: crossingCount(routes),
sharedRouteCells: sharedRouteCellCount(routes),
overflow: Math.max(0, width - viewport),
}
}
function requireOutputLines(output: string, lines: readonly string[], owner: string, violations: string[]): void {
for (const line of lines.map((line) => line.trim()).filter(Boolean)) {
if (!output.includes(line)) violations.push(`${owner} content missing: ${JSON.stringify(line)}`)
}
}
function validateRoutes(
routes: readonly AuditedRoute[],
bounds: ReadonlyMap<string, Bounds>,
bodyIds: readonly string[],
violations: string[],
): void {
for (const [index, route] of routes.entries()) {
if (route.points.length < 2) {
violations.push(`route ${index} ${route.from}->${route.to} is empty`)
continue
}
if (!isOrthogonal(route.points))
violations.push(`route ${index} ${route.from}->${route.to} is not finite and orthogonal`)
const from = bounds.get(route.from)
const to = bounds.get(route.to)
if (!from || !to) {
violations.push(`route ${index} ${route.from}->${route.to} has a missing endpoint bound`)
continue
}
if (!pointTouchesBounds(route.points[0], from))
violations.push(`route ${index} does not touch source ${route.from}`)
if (!pointTouchesBounds(route.points.at(-1)!, to))
violations.push(`route ${index} does not touch target ${route.to}`)
if (!isOrthogonal(route.points)) continue
const bodySpace = SpatialIndex.empty().add(
...bodyIds.flatMap((id) => {
const bound = bounds.get(id)
return id === route.from || id === route.to || !bound
? []
: [spatialRectClaim(`body:${id}`, `body:${id}`, "body", bound)]
}),
)
const conflicts = bodySpace.conflicts(spatialPathClaim(`route:${index}`, `route:${index}`, "route", route.points))
for (const id of new Set(conflicts.map((conflict) => conflict.existing.owner.slice("body:".length)))) {
violations.push(`route ${index} ${route.from}->${route.to} intersects unrelated body ${id}`)
}
}
}
function validateBodies(bounds: ReadonlyMap<string, Bounds>, ids: readonly string[], violations: string[]): void {
let occupied = SpatialIndex.empty()
for (const id of ids) {
const bound = bounds.get(id)
if (!bound) {
violations.push(`missing body bound ${id}`)
continue
}
if (!finiteBounds(bound)) {
violations.push(`body ${id} has invalid bounds`)
continue
}
const claim = spatialRectClaim(`body:${id}`, `body:${id}`, "body", bound)
for (const otherId of new Set(
occupied.conflicts(claim).map((conflict) => conflict.existing.owner.slice("body:".length)),
)) {
violations.push(`bodies ${id} and ${otherId} overlap`)
}
occupied = occupied.add(claim)
}
}
function auditFlowchart(fixture: LayoutFixture, viewport: (typeof auditViewports)[number]): LayoutAudit {
const violations: string[] = []
const diagram = parseMermaidFlowchartDiagram(fixture.source)
const layout = layoutFlowchartDiagram(diagram, { compact: true, layoutMaxWidth: viewport })
const grid = drawFlowchartDiagramGrid(diagram, { compact: true, layoutMaxWidth: viewport })
const output = grid.toString({ trimTop: true, trimBottom: true })
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
const routes = layout.routes.map((route) => ({ from: route.edge.from, to: route.edge.to, points: route.points }))
const bodyIds = layout.diagram.nodes.map((node) => node.id)
if (!Number.isFinite(size.width) || !Number.isFinite(size.height) || size.width <= 0 || size.height <= 0)
violations.push("rendered grid has invalid dimensions")
if (!Number.isFinite(layout.width) || !Number.isFinite(layout.height) || layout.width <= 0 || layout.height <= 0)
violations.push("layout has invalid dimensions")
if (layout.routes.length !== layout.diagram.edges.filter((edge) => !edge.orderOnly).length)
violations.push("rendered route count does not match visible edge count")
validateBodies(layout.bounds, bodyIds, violations)
validateRoutes(routes, layout.bounds, bodyIds, violations)
for (const node of layout.diagram.nodes)
requireOutputLines(output, layout.bounds.get(node.id)?.lines ?? [], `node ${node.id}`, violations)
for (const route of layout.routes) {
if (!route.edge.label) continue
requireOutputLines(
output,
flowchartRouteLabelLayout(route, diagramTextWidth).lines,
`edge ${route.edge.from}->${route.edge.to}`,
violations,
)
const targets = new Set(
layout.diagram.edges.filter((edge) => edge.label && edge.from === route.edge.from).map((edge) => edge.to),
)
const sources = new Set(
layout.diagram.edges.filter((edge) => edge.label && edge.to === route.edge.to).map((edge) => edge.from),
)
const label = flowchartRouteLabelLayout(route, diagramTextWidth)
if (
fixture.family === "grouped-fanout" &&
(targets.size > 1 || sources.size > 1) &&
label.point.x + label.width > viewport
) {
violations.push(`grouped edge ${route.edge.from}->${route.edge.to} label exceeds viewport`)
}
}
for (const subgraph of layout.diagram.subgraphs ?? []) {
const bound = layout.subgraphBounds.get(subgraph.id)
if (!bound || !finiteBounds(bound)) violations.push(`subgraph ${subgraph.id} has invalid bounds`)
requireOutputLines(output, splitDiagramLines(subgraph.label), `subgraph ${subgraph.id}`, violations)
for (const nodeId of subgraph.nodeIds) {
const node = layout.bounds.get(nodeId)
if (bound && node && !boundsContain(bound, node))
violations.push(`subgraph ${subgraph.id} does not contain ${nodeId}`)
}
}
const subgraphs = layout.diagram.subgraphs ?? []
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
const ancestorOf = (ancestor: string, id: string) => {
let parentId = subgraphById.get(id)?.parentId
while (parentId) {
if (parentId === ancestor) return true
parentId = subgraphById.get(parentId)?.parentId
}
return false
}
for (const [index, subgraph] of subgraphs.entries()) {
const bound = layout.subgraphBounds.get(subgraph.id)
if (!bound) continue
for (const other of subgraphs.slice(index + 1)) {
if (ancestorOf(subgraph.id, other.id) || ancestorOf(other.id, subgraph.id)) continue
const otherBound = layout.subgraphBounds.get(other.id)
if (otherBound && boundsOverlap(bound, otherBound)) {
violations.push(`subgraphs ${subgraph.id} and ${other.id} overlap`)
}
}
}
return { fixture, viewport, output, metrics: metrics(size.width, size.height, routes, viewport), violations }
}
function stateRoute(route: StateTransitionRenderPlan): AuditedRoute {
return {
from: route.route.transition.from,
to: route.route.transition.to,
points: route.path.map(([x, y]) => ({ x, y })),
}
}
function auditState(fixture: LayoutFixture, viewport: (typeof auditViewports)[number]): LayoutAudit {
const violations: string[] = []
const parsed = parseMermaidStateDiagram(fixture.source)
const drawing = createStateDiagramDrawing(parsed, { minStateGap: 5, layoutMaxWidth: viewport })
const diagram = drawing.diagram
const layout = drawing.layout
const plans = drawing.transitionPlans
const grid = drawing.grid
const routes = plans.map(stateRoute)
const output = grid.toString({ trimTop: true, trimBottom: true })
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
const bodyIds = diagram.states.filter((state) => !isHiddenCompositeMarker(state)).map((state) => state.id)
if (!Number.isFinite(size.width) || !Number.isFinite(size.height) || size.width <= 0 || size.height <= 0)
violations.push("rendered grid has invalid dimensions")
if (plans.length !== diagram.transitions.length)
violations.push("rendered route count does not match visible transition count")
validateBodies(layout.bounds, bodyIds, violations)
validateRoutes(routes, layout.bounds, bodyIds, violations)
if (diagram.direction === "BT" && fixture.family === "chain") {
for (const transition of diagram.transitions) {
if (transition.from === transition.to) continue
const from = layout.bounds.get(transition.from)
const to = layout.bounds.get(transition.to)
if (from && to && from.centerY <= to.centerY) {
violations.push(`BT transition ${transition.from}->${transition.to} does not travel upward`)
}
}
}
for (const state of diagram.states) {
if (isHiddenCompositeMarker(state)) continue
requireOutputLines(output, layout.sizes.get(state.id)?.lines ?? [state.label], `state ${state.id}`, violations)
}
for (const plan of plans) {
if (!plan.route.transition.label) continue
if (!plan.label)
violations.push(`transition ${plan.route.transition.from}->${plan.route.transition.to} has no label layout`)
requireOutputLines(
output,
plan.label?.lines ?? [],
`transition ${plan.route.transition.from}->${plan.route.transition.to}`,
violations,
)
}
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
const descendantOf = (id: string, compositeId: string) => {
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
while (parentId) {
if (parentId === compositeId) return true
parentId = compositesById.get(parentId)?.parentId
}
return false
}
for (const composite of diagram.composites) {
const bound = layout.compositeBounds.get(composite.id)
if (!bound || !finiteBounds(bound)) {
violations.push(`composite ${composite.id} has invalid bounds`)
continue
}
requireOutputLines(output, splitDiagramLines(composite.label), `composite ${composite.id}`, violations)
for (const state of diagram.states.filter(
(state) => !isHiddenCompositeMarker(state) && descendantOf(state.id, composite.id),
)) {
const stateBound = layout.bounds.get(state.id)
if (stateBound && !boundsContain(bound, stateBound))
violations.push(`composite ${composite.id} does not contain ${state.id}`)
}
}
for (const [index, note] of layout.noteBounds.entries()) {
if (!finiteBounds(note)) violations.push(`note ${index} has invalid bounds`)
requireOutputLines(output, note.lines, `note ${index}`, violations)
for (const id of bodyIds) {
const bound = layout.bounds.get(id)
if (bound && boundsOverlap(note, bound)) violations.push(`note ${index} overlaps state ${id}`)
}
for (const other of layout.noteBounds.slice(index + 1)) {
if (boundsOverlap(note, other)) violations.push(`notes ${index} and ${other.id} overlap`)
}
const target = layout.bounds.get(note.note.target)
if (!target) {
violations.push(`note ${index} has no target bound`)
continue
}
for (const point of expandedPath(stateDiagramNoteConnector(note, target).points)) {
for (const id of bodyIds) {
if (id === note.note.target) continue
const bound = layout.bounds.get(id)
if (bound && pointInBounds(point, bound)) violations.push(`note ${index} connector intersects state ${id}`)
}
for (const other of layout.noteBounds) {
if (other === note) continue
if (pointInBounds(point, other)) violations.push(`note ${index} connector intersects note ${other.id}`)
}
}
}
return { fixture, viewport, output, metrics: metrics(size.width, size.height, routes, viewport), violations }
}
export function auditFixture(fixture: LayoutFixture, viewport: (typeof auditViewports)[number] = 120): LayoutAudit {
return fixture.kind === "flowchart" ? auditFlowchart(fixture, viewport) : auditState(fixture, viewport)
}
export function auditAllFixtures(): LayoutAudit[] {
return layoutFixtures().flatMap((fixture, index) => {
if (fixture.curated) return auditViewports.map((viewport) => auditFixture(fixture, viewport))
if (fixture.kind === "flowchart") {
return auditFixture(fixture, auditViewports[index % auditViewports.length])
}
const viewport = fixture.profile === "short" ? 60 : fixture.profile === "unicode" ? 80 : 120
return auditFixture(fixture, viewport)
})
}
function percentile(values: readonly number[], ratio: number): number {
if (values.length === 0) return 0
return [...values].sort((left, right) => left - right)[Math.ceil(values.length * ratio) - 1] ?? 0
}
export function summarizeAudits(audits: readonly LayoutAudit[]) {
const summarize = (selected: readonly LayoutAudit[]) => ({
runs: selected.length,
sources: new Set(selected.map((audit) => audit.fixture.id)).size,
violations: selected.reduce((total, audit) => total + audit.violations.length, 0),
area: {
p50: percentile(
selected.map((audit) => audit.metrics.area),
0.5,
),
p95: percentile(
selected.map((audit) => audit.metrics.area),
0.95,
),
max: Math.max(0, ...selected.map((audit) => audit.metrics.area)),
},
bends: {
p95: percentile(
selected.map((audit) => audit.metrics.bends),
0.95,
),
max: Math.max(0, ...selected.map((audit) => audit.metrics.bends)),
},
crossings: {
total: selected.reduce((total, audit) => total + audit.metrics.crossings, 0),
max: Math.max(0, ...selected.map((audit) => audit.metrics.crossings)),
},
routeLength: {
p95: percentile(
selected.map((audit) => audit.metrics.routeLength),
0.95,
),
max: Math.max(0, ...selected.map((audit) => audit.metrics.routeLength)),
},
sharedRouteCells: {
p95: percentile(
selected.map((audit) => audit.metrics.sharedRouteCells),
0.95,
),
max: Math.max(0, ...selected.map((audit) => audit.metrics.sharedRouteCells)),
},
overflow: {
p95: percentile(
selected.map((audit) => audit.metrics.overflow),
0.95,
),
max: Math.max(0, ...selected.map((audit) => audit.metrics.overflow)),
},
})
return {
total: summarize(audits),
flowchart: summarize(audits.filter((audit) => audit.fixture.kind === "flowchart")),
state: summarize(audits.filter((audit) => audit.fixture.kind === "state")),
}
}
export function worstAudits(audits: readonly LayoutAudit[], metric: keyof LayoutMetrics, limit = 10): LayoutAudit[] {
return [...audits]
.sort(
(left, right) => right.metrics[metric] - left.metrics[metric] || left.fixture.id.localeCompare(right.fixture.id),
)
.slice(0, limit)
}
-48
View File
@@ -334,33 +334,6 @@ flowchart LR
expect(testRenderer.captureCharFrame()).toContain("GLOBAL registry")
})
test("folds a horizontal state diagram to the Markdown context width", async () => {
const testRenderer = await createTestRenderer({ width: 60, height: 48 })
renderer = testRenderer.renderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-horizontal-state",
content: `\`\`\`mermaid
stateDiagram-v2
direction LR
[*] --> A
A --> B: first
B --> C: second
C --> D: third
D --> [*]
\`\`\``,
syntaxStyle,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, testRenderer.renderOnce)
const diagram = markdown.getChildren()[0] as CodeRenderable
expect(diagram.scrollWidth).toBeLessThanOrEqual(diagram.width)
expect(diagram.scrollWidth).toBeLessThanOrEqual(60)
expect(testRenderer.captureCharFrame()).toContain("third")
})
test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
@@ -384,27 +357,6 @@ stateDiagram-v2
expect(frame).not.toContain("stateDiagram-v2")
})
test("sizes a standalone state choice after trimming leading rows", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 6 })
renderer = testRenderer.renderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-state-choice",
content: `\`\`\`mermaid
stateDiagram-v2
direction LR
state Decision <<choice>>
\`\`\``,
syntaxStyle,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, testRenderer.renderOnce)
expect(markdown.getChildren()[0]?.height).toBe(1)
expect(testRenderer.captureCharFrame()).toContain("◆")
})
test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 18 })
renderer = testRenderer.renderer

Some files were not shown because too many files have changed in this diff Show More