Compare commits

...
4 Commits
16 changed files with 1131 additions and 147 deletions
+5 -5
View File
@@ -35,15 +35,15 @@ export const DEFAULT_SEARCH_LIMIT = 100
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String,
path: RelativePath.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
path: Schema.optionalKey(RelativePath),
limit: Schema.optionalKey(PositiveInt),
}) {}
export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
pattern: Schema.String,
path: RelativePath.pipe(Schema.optional),
include: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
path: Schema.optionalKey(RelativePath),
include: Schema.optionalKey(Schema.String),
limit: Schema.optionalKey(PositiveInt),
}) {}
export const Event = FileSystem.Event
+1 -1
View File
@@ -25,7 +25,7 @@ export const Input = Schema.Struct({
}),
oldString: Schema.String.annotate({ description: "Exact text to replace" }),
newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
replaceAll: Schema.Boolean.pipe(Schema.optional).annotate({
replaceAll: Schema.optionalKey(Schema.Boolean).annotate({
description: "Replace all exact occurrences of oldString (default false)",
}),
})
+1 -1
View File
@@ -16,7 +16,7 @@ export const name = "glob"
export const Input = Schema.Struct({
pattern: FileSystem.GlobInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: RelativePath.pipe(Schema.optional).annotate({
path: Schema.optionalKey(RelativePath).annotate({
description: "Directory to search. Defaults to the current working directory.",
}),
limit: FileSystem.GlobInput.fields.limit.annotate({
+1 -1
View File
@@ -20,7 +20,7 @@ export const Input = Schema.Struct({
).annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)",
}),
path: RelativePath.pipe(Schema.optional).annotate({
path: Schema.optionalKey(RelativePath).annotate({
description: "File or directory to search. Defaults to the current working directory.",
}),
include: FileSystem.GrepInput.fields.include.annotate({
+71 -30
View File
@@ -1,7 +1,7 @@
export * as ReadTool from "./read"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { dirname } from "path"
import { basename, dirname, join } from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -16,15 +16,15 @@ export const name = "read"
const FILENAME = "AGENTS.md"
const SUPPORTED_MEDIA_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf"])
const LocationInput = Schema.Struct({
path: Schema.String,
path: Schema.String.annotate({ description: "File or directory to read" }),
offset: ReadToolFileSystem.PageInput.fields.offset.annotate({
description: "The 1-based directory entry or text line offset to start reading from",
description: "The line or directory entry to start reading from (1-based)",
}),
limit: ReadToolFileSystem.PageInput.fields.limit.annotate({
description: "The maximum number of directory entries or text lines to read",
description: "The maximum number of lines or directory entries to read (defaults to 2000)",
}),
})
const Input = LocationInput
export const Input = LocationInput
const Output = Schema.Union([
ReadToolFileSystem.FileContent,
ReadToolFileSystem.TextPage,
@@ -48,7 +48,7 @@ export const Plugin = {
name,
options: { codemode: false },
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
input: Input,
output: Output,
execute: (input, context) => {
@@ -69,7 +69,6 @@ export const Plugin = {
})
const resource = target.resource
const absolute = AbsolutePath.make(target.canonical)
const type = yield* reader.inspect(absolute)
yield* permission.assert({
action: name,
resources: [resource],
@@ -78,6 +77,9 @@ export const Plugin = {
agent: context.agent,
source,
})
const type = yield* reader.inspect(absolute).pipe(
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
)
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
@@ -114,30 +116,12 @@ export const Plugin = {
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.map((output) => {
// Image base64 reaches the model through content items; avoid a second
// unresized copy in model text.
const content =
output.type === "file" && output.encoding === "base64"
? SUPPORTED_MEDIA_MIMES.has(output.mime)
? ([
{
type: "text",
text:
output.mime === "application/pdf" ? "PDF read successfully" : "Image read successfully",
},
{
type: "file",
uri: `data:${output.mime};base64,${output.content}`,
mime: output.mime,
name: input.path,
},
] as const)
: JSON.stringify({ ...output, content: "" }, null, 2)
: JSON.stringify(output, null, 2)
return { output, content }
}),
Effect.map((output) => ({
output,
content: toModelContent(input.path, input.offset, output),
})),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
@@ -154,5 +138,62 @@ export const Plugin = {
),
)
.pipe(Effect.orDie)
const missing = Effect.fn("ReadTool.missing")(function* (input: string, canonical: string) {
const base = basename(input).toLowerCase()
const suggestions = yield* fs.readDirectory(dirname(canonical)).pipe(
Effect.map((entries) =>
entries
.filter((entry) => {
const candidate = entry.toLowerCase()
return candidate.includes(base) || base.includes(candidate)
})
.map((entry) => join(dirname(input), entry))
.slice(0, 3),
),
Effect.catch(() => Effect.succeed([] as string[])),
)
const message =
suggestions.length === 0
? `File not found: ${input}`
: `File not found: ${input}\n\nDid you mean one of these?\n${suggestions.join("\n")}`
return yield* new ToolFailure({ message })
})
}),
}
export const toModelContent = (path: string, offset: number | undefined, output: typeof Output.Type) => {
if (output.type === "file" && output.encoding === "base64")
return [
{ type: "text", text: output.mime === "application/pdf" ? "PDF read successfully" : "Image read successfully" },
{
type: "file",
uri: `data:${output.mime};base64,${output.content}`,
mime: output.mime,
name: path,
},
] as const
if (output.type === "list-page") {
const start = offset ?? 1
const content = [
output.entries.length === 0
? `Read directory ${path}, 0 entries`
: `Read directory ${path}, entries ${start}-${start + output.entries.length - 1}`,
]
output.entries.forEach((entry) => content.push(entry.path))
if (output.truncated && output.next !== undefined)
content.push(`[Output truncated. Continue reading with offset: ${output.next}]`)
return content.join("\n")
}
const start = output.type === "text-page" ? output.offset : 1
const lines = output.content === "" ? [] : output.content.replace(/\n$/, "").split("\n")
const content = [
lines.length === 0 ? `Read file ${path}, 0 lines` : `Read file ${path}, lines ${start}-${start + lines.length - 1}`,
]
lines.forEach((line, index) => content.push(`${start + index}: ${line}`))
if (output.type === "text-page" && output.truncated && output.next !== undefined)
content.push(`[Output truncated. Continue reading with offset: ${output.next}]`)
return content.join("\n")
}
+8 -9
View File
@@ -24,32 +24,31 @@ const BACKGROUND_INSTRUCTION =
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
workdir: Schema.String.pipe(Schema.optional).annotate({
workdir: Schema.optionalKey(Schema.String).annotate({
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
}),
timeout: NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS))
.pipe(Schema.optional)
timeout: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)))
.annotate({
description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`,
}),
background: Schema.Boolean.pipe(Schema.optional).annotate({
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
}),
})
const StructuredOutput = Schema.Struct({
exit: Schema.Number.pipe(Schema.optional),
shellID: Schema.String.pipe(Schema.optional),
exit: Schema.optionalKey(Schema.Number),
shellID: Schema.optionalKey(Schema.String),
truncated: Schema.Boolean,
timeout: Schema.Boolean.pipe(Schema.optional),
timeout: Schema.optionalKey(Schema.Boolean),
})
const Output = Schema.Struct({
...StructuredOutput.fields,
output: Schema.String,
status: Schema.Literals(["completed", "running"]).pipe(Schema.optional),
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
status: Schema.optionalKey(Schema.Literals(["completed", "running"])),
warnings: Schema.optionalKey(Schema.Array(Schema.String)),
})
type Output = typeof Output.Type
+1 -1
View File
@@ -19,7 +19,7 @@ export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
background: Schema.Boolean.pipe(Schema.optional).annotate({
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
}),
+3 -3
View File
@@ -18,14 +18,14 @@ export const description = `Fetch content from an HTTP or HTTPS URL and return i
Use a more targeted tool when one is available. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.`
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
const Timeout = Schema.Finite.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
export const Input = Schema.Struct({
url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }),
format: Schema.Literals(["text", "markdown", "html"])
.annotate({ description: "The format to return the content in. Defaults to markdown." })
.pipe(Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
timeout: Timeout.pipe(Schema.optional).annotate({
.pipe(Schema.withDecodingDefaultKey(Effect.succeed("markdown" as const))),
timeout: Schema.optionalKey(Timeout).annotate({
description: `Optional timeout in seconds (maximum: ${MAX_TIMEOUT_SECONDS})`,
}),
})
+9 -9
View File
@@ -6,7 +6,7 @@ import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath, PositiveInt, RelativePath } from "../schema"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
@@ -70,8 +70,8 @@ export type ReadError =
| PathKindError
export const PageInput = Schema.Struct({
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
offset: Schema.optionalKey(NonNegativeInt),
limit: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES))),
})
export type PageInput = typeof PageInput.Type
@@ -87,14 +87,14 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
mime: Schema.String,
offset: PositiveInt,
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
next: Schema.optionalKey(PositiveInt),
}) {}
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
type: Schema.Literal("list-page"),
entries: Schema.Array(FileSystem.Entry),
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
next: Schema.optionalKey(PositiveInt),
}) {}
export interface Interface {
@@ -240,8 +240,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
mime: FSUtil.mimeType(real),
}
}
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder("utf-8", { fatal: true })
let pending = ""
@@ -334,8 +334,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
const real = yield* fs.realPath(input)
const items = yield* fs.readDirectoryEntries(real)
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const entries = yield* Effect.forEach(
items,
(item) =>
+83 -2
View File
@@ -100,8 +100,89 @@ const outputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema
const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
const document = Schema.toJsonSchemaDocument(schema)
if (Object.keys(document.definitions).length === 0) return document.schema
return { ...document.schema, $defs: document.definitions }
// Effect emits valid JSON Schema that some inference providers handle poorly. Simplify it
// without changing validation: `{ type: "integer", allOf: [{ minimum: 0 }] }` becomes
// `{ type: "integer", minimum: 0 }` only when no keyword would be overwritten. Named schemas
// emit `$ref` plus root `$defs`; inline acyclic local references so providers receive the full
// nested schema directly, then remove unused `$defs`. Recursive references stay intact because
// expanding them would never terminate.
const normalized = flattenAllOf(
Object.keys(document.definitions).length === 0
? document.schema
: { ...document.schema, $defs: document.definitions },
)
return dropDefinitionsIfResolved(inlineLocalReferences(normalized)) as JsonSchema.JsonSchema
}
const flattenAllOf = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(flattenAllOf)
if (typeof value !== "object" || value === null) return value
const schema = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, flattenAllOf(item)]))
if (!Array.isArray(schema.allOf) || !schema.allOf.every(isRecord) || !canFlattenAllOf(schema.allOf, schema))
return schema
const { allOf, ...rest } = schema
return flattenAllOf({ ...Object.assign({}, ...allOf), ...rest })
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const canFlattenAllOf = (allOf: ReadonlyArray<Record<string, unknown>>, parent: Record<string, unknown>) => {
const keys = new Set(Object.keys(parent).filter((key) => key !== "allOf"))
return allOf.every((item) =>
Object.keys(item).every((key) => {
if (keys.has(key)) return false
keys.add(key)
return true
}),
)
}
const inlineLocalReferences = (
value: unknown,
definitions?: Record<string, unknown>,
seen = new Set<string>(),
): unknown => {
if (Array.isArray(value)) return value.map((item) => inlineLocalReferences(item, definitions, seen))
if (!isRecord(value)) return value
const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined)
if (typeof value.$ref === "string" && localDefinitions) {
const segment = value.$ref.match(/^#\/\$defs\/([^/]+)$/)?.[1]
const name = segment?.replaceAll("~1", "/").replaceAll("~0", "~")
if (name && !seen.has(name)) {
const target = localDefinitions[name]
if (target) {
const { $ref: _, ...rest } = value
const resolvedTarget = inlineLocalReferences(target, localDefinitions, new Set(seen).add(name))
const resolvedSiblings = inlineLocalReferences(rest, localDefinitions, seen)
if (!isRecord(resolvedTarget) || !isRecord(resolvedSiblings)) return resolvedTarget
if (canMergeRecords(resolvedTarget, resolvedSiblings)) return { ...resolvedTarget, ...resolvedSiblings }
return { allOf: [resolvedTarget, resolvedSiblings] }
}
}
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, inlineLocalReferences(item, localDefinitions, seen)]),
)
}
const canMergeRecords = (left: Record<string, unknown>, right: Record<string, unknown>) =>
Object.keys(left).every((key) => !(key in right))
const dropDefinitionsIfResolved = (value: unknown): unknown => {
if (!isRecord(value) || hasLocalReference(value)) return value
const { $defs: _, ...rest } = value
return rest
}
const hasLocalReference = (value: unknown): boolean => {
if (Array.isArray(value)) return value.some(hasLocalReference)
if (!isRecord(value)) return false
if (typeof value.$ref === "string" && value.$ref.startsWith("#/$defs/")) return true
return Object.values(value).some(hasLocalReference)
}
export const normalizeContent = (value: string | ReadonlyArray<Tool.Content> | undefined, output?: unknown) => {
+77 -25
View File
@@ -5,12 +5,13 @@ import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { Image } from "@opencode-ai/core/image"
import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location"
@@ -40,13 +41,23 @@ const readToolNode = makeLocationNode({
const assertions: Permission.AssertInput[] = []
const missingPath = "__missing_read_target__.txt"
const missingAbsolutePath = path.join(process.cwd(), missingPath)
const notFound = (target: string) =>
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "stat",
pathOrDescriptor: target,
})
const readCalls: {
input: AbsolutePath
page: ReadToolFileSystem.PageInput
}[] = []
const listCalls: ReadToolFileSystem.PageInput[] = []
let listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
let resolvedType: "file" | "directory" = "file"
let resolveFailure: unknown
let inspectFailure: ReadToolFileSystem.InspectError | undefined
let directoryEntries: string[] = []
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
type: "file",
uri: "file:///README.md",
@@ -59,7 +70,12 @@ let readFailure: ReadToolFileSystem.ReadError | undefined
const reader = Layer.succeed(
ReadToolFileSystem.Service,
ReadToolFileSystem.Service.of({
inspect: () => (resolveFailure === undefined ? Effect.succeed(resolvedType) : Effect.die(resolveFailure)),
inspect: () =>
resolveFailure !== undefined
? Effect.die(resolveFailure)
: inspectFailure !== undefined
? Effect.fail(inspectFailure)
: Effect.succeed(resolvedType),
read: (input, _resource, page = {}) => {
readCalls.push({ input, page })
if (readFailure !== undefined) return Effect.fail(readFailure)
@@ -68,7 +84,7 @@ const reader = Layer.succeed(
list: (_path, input = {}) =>
Effect.sync(() => {
listCalls.push(input)
return new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
return listResult
}),
}),
)
@@ -107,6 +123,7 @@ const testFileSystem = Layer.effect(
Effect.succeed(
FSUtil.Service.of({
...fs,
readDirectory: () => Effect.succeed(directoryEntries),
realPath: (path) =>
path === missingAbsolutePath
? Effect.fail(
@@ -130,8 +147,6 @@ const mutation = Layer.succeed(
LocationMutation.Service,
LocationMutation.Service.of({
resolve: (input) => {
if (input.path === missingPath)
return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" }))
const canonical = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
@@ -183,6 +198,8 @@ describe("ReadTool", () => {
allow = true
resolvedType = "file"
resolveFailure = undefined
inspectFailure = undefined
directoryEntries = []
readResult = {
type: "file",
uri: "file:///README.md",
@@ -192,6 +209,7 @@ describe("ReadTool", () => {
mime: "text/plain",
}
readFailure = undefined
listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
})
it.effect("registers, authorizes, and reads through the location filesystem", () =>
@@ -219,6 +237,7 @@ describe("ReadTool", () => {
encoding: "utf8",
mime: "text/plain",
})
expect(execution.content).toEqual([{ type: "text", text: "Read file README.md, lines 1-1\n1: hello" }])
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
expect(readCalls).toEqual([
{
@@ -658,6 +677,14 @@ describe("ReadTool", () => {
it.effect("returns missing paths as model-visible tool failures", () =>
Effect.gen(function* () {
inspectFailure = notFound(missingAbsolutePath)
directoryEntries = [
"__missing_read_target__.txt.bak",
"copy___missing_read_target__.txt",
"old___missing_read_target__.txt",
"other___missing_read_target__.txt",
"unrelated.txt",
]
const registry = yield* Tool.Service
expect(
@@ -666,10 +693,14 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } },
}),
// The message-less PathError cause must not erase the tool's curated
// failure message; the canonical error is the sole authority.
).toEqual({ status: "error", error: { type: "tool.execution", message: `Unable to read ${missingPath}` } })
expect(assertions).toEqual([])
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: `File not found: ${missingPath}\n\nDid you mean one of these?\n__missing_read_target__.txt.bak\ncopy___missing_read_target__.txt\nold___missing_read_target__.txt`,
},
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: [missingPath], save: ["*"] }])
expect(readCalls).toEqual([])
}),
)
@@ -677,10 +708,18 @@ describe("ReadTool", () => {
it.effect("lists a bounded directory page through read", () =>
Effect.gen(function* () {
resolvedType = "directory"
listResult = new ReadToolFileSystem.ListPage({
type: "list-page",
entries: [
FileSystem.Entry.make({ path: RelativePath.make("components/"), type: "directory" }),
FileSystem.Entry.make({ path: RelativePath.make("index.ts"), type: "file" }),
],
truncated: true,
next: 4,
})
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
@@ -689,8 +728,15 @@ describe("ReadTool", () => {
name: "read",
input: { path: "src", offset: 2, limit: 10 },
},
}),
).toMatchObject({ status: "completed", output: { entries: [], truncated: false } })
})
expect(result).toMatchObject({ status: "completed", output: { entries: listResult.entries, truncated: true, next: 4 } })
if (result.status !== "completed") return
expect(result.content).toEqual([
{
type: "text",
text: "Read directory src, entries 2-3\ncomponents/\nindex.ts\n[Output truncated. Continue reading with offset: 4]",
},
])
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
}),
@@ -744,21 +790,27 @@ describe("ReadTool", () => {
})
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-large",
name: "read",
input: { path: "large.txt", offset: 2, limit: 1 },
},
}),
).toMatchObject({
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-large",
name: "read",
input: { path: "large.txt", offset: 2, limit: 1 },
},
})
expect(result).toMatchObject({
status: "completed",
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
})
if (result.status !== "completed") return
expect(result.content).toEqual([
{
type: "text",
text: "Read file large.txt, lines 2-2\n2: hello\n[Output truncated. Continue reading with offset: 3]",
},
])
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },
])
+61
View File
@@ -32,6 +32,67 @@ test("tools are structural values", async () => {
})
})
test("Effect tool schemas use exact optional keys and flatten compatible constraints", () => {
const tool: Info = {
name: "constraints",
description: "Constraints",
input: Schema.Struct({
offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
code: Schema.String.check(Schema.isPattern(/^a/), Schema.isPattern(/z$/)),
}),
execute: () => Effect.succeed({ content: "unused" }),
}
expect(definition(tool).inputSchema).toEqual({
type: "object",
properties: {
offset: { type: "integer", minimum: 0 },
code: { type: "string", allOf: [{ pattern: "^a" }, { pattern: "z$" }] },
},
required: ["code"],
additionalProperties: false,
})
})
test("Effect tool schemas inline named child schemas", () => {
const Child = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Child" })
const tool: Info = {
name: "references",
description: "References",
input: Schema.Struct({ child: Child.annotate({ description: "Child value" }) }),
execute: () => Effect.succeed({ content: "unused" }),
}
expect(definition(tool).inputSchema).toEqual({
type: "object",
properties: {
child: {
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
additionalProperties: false,
description: "Child value",
},
},
required: ["child"],
additionalProperties: false,
})
})
test("Effect tool schemas resolve escaped definition names", () => {
const Slash = Schema.Struct({ slash: Schema.String }).annotate({ identifier: "A/B" })
const Tilde = Schema.Struct({ tilde: Schema.String }).annotate({ identifier: "A~B" })
const tool: Info = {
name: "escaped-references",
description: "Escaped references",
input: Schema.Struct({ slash: Slash, tilde: Tilde }),
execute: () => Effect.succeed({ content: "unused" }),
}
expect(JSON.stringify(definition(tool).inputSchema)).not.toContain("$ref")
expect(JSON.stringify(definition(tool).inputSchema)).not.toContain("$defs")
})
test("portable schemas validate and describe typed tools", async () => {
const input = {
"~standard": {
+64 -45
View File
@@ -49,6 +49,67 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
if (history.length > connectionHistoryLimit) history.shift()
}
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
// Bound the initial handshake and tie this request to the stream lifetime.
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
signal.addEventListener("abort", cancel, { once: true })
try {
// Open the event stream and validate its initial handshake.
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
log.info("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done) {
const error =
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
return { error, connectedAt }
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
// Publish the connected state before forwarding live events.
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
log.info("event stream connected")
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if ("durable" in event.value)
log.debug("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
events.emit(event.value.type, event.value)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
function start() {
stream?.abort()
const controller = new AbortController()
@@ -56,53 +117,11 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
void (async () => {
let attempt = 0
while (!abort.signal.aborted && !controller.signal.aborted) {
let connectedAt: number | undefined
const request = new AbortController()
const cancel = () => request.abort(controller.signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
controller.signal.addEventListener("abort", cancel, { once: true })
const error = await (async () => {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
log.info("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return undefined
if (first.done)
return request.signal.reason instanceof Error
? request.signal.reason
: new Error("Event stream disconnected")
if (first.value.type !== "server.connected")
return new Error("Event stream did not start with server.connected")
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
log.info("event stream connected")
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return undefined
if (event.done) return new Error("Event stream disconnected")
if ("durable" in event.value)
log.debug("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
events.emit(event.value.type, event.value)
}
return undefined
})()
.catch((error) => error)
.finally(() => {
request.abort()
clearTimeout(timeout)
controller.signal.removeEventListener("abort", cancel)
})
const result = await connect(controller.signal, attempt)
if (abort.signal.aborted || controller.signal.aborted) return
if (connectedAt !== undefined && Date.now() - connectedAt >= 1_000) attempt = 0
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) attempt = 0
attempt += 1
const message = errorMessage(error)
const message = errorMessage(result.error)
record("disconnected", attempt, message)
log.info("event stream disconnected", {
attempt,
+66 -4
View File
@@ -58,7 +58,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
const backgroundPanel = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
const backgroundMenu = mode === "light" ? "$hue.neutral.400" : "$hue.neutral.600"
return {
return referenceHues({
hue: {
gray: neutralScale(theme, mode),
...Object.fromEntries(
@@ -67,8 +67,8 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
}),
),
accent: ambiguous(theme.accent) ? "$hue.gray" : hueScale(theme.accent, mode),
interactive: ambiguous(theme.primary) ? "$hue.gray" : hueScale(theme.primary, mode),
accent: hues.byToken.accent ? `$hue.${hues.byToken.accent}` : "$hue.gray",
interactive: hues.byToken.primary ? `$hue.${hues.byToken.primary}` : "$hue.gray",
neutral: "$hue.gray",
},
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
@@ -176,7 +176,56 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
},
},
"@context:overlay": { background: { default: "$background.surface.overlay" } },
})
}
function referenceHues(theme: FileThemeDefinition): FileThemeDefinition {
const definitions = theme.hue as Record<string, string | Partial<Record<HueStep, string>>> | undefined
if (!definitions) return theme
const scales = new Map<string, Partial<Record<HueStep, string>>>()
function resolve(name: string, chain: string[] = []): Partial<Record<HueStep, string>> | undefined {
const cached = scales.get(name)
if (cached) return cached
if (chain.includes(name)) return
const value = definitions?.[name]
if (!value) return
if (typeof value !== "string") {
scales.set(name, value)
return value
}
const target = /^\$hue\.([^.]+)$/.exec(value)?.[1]
if (!target) return
const scale = resolve(target, [...chain, name])
if (scale) scales.set(name, scale)
return scale
}
const references = new Map<string, string>()
const index = (name: string, overwrite: boolean) => {
const scale = resolve(name)
if (!scale) return
HueStep.literals.forEach((step) => {
const color = scale[step]
if (!color || (!overwrite && references.has(color.toLowerCase()))) return
references.set(color.toLowerCase(), `$hue.${name}.${step}`)
})
}
chromaticHues.forEach((name) => index(name, false))
index("gray", false)
index("accent", true)
index("interactive", true)
index("neutral", true)
function replace(value: unknown): unknown {
if (typeof value === "string") return references.get(value.toLowerCase()) ?? value
if (!value || typeof value !== "object" || Array.isArray(value)) return value
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replace(item)]))
}
return Object.fromEntries(
Object.entries(theme).map(([key, value]) => [key, key === "hue" || key === "categorical" ? value : replace(value)]),
) as FileThemeDefinition
}
function inferHues(theme: Theme, mode: "light" | "dark") {
@@ -189,7 +238,7 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
["info", theme.info],
["secondary", theme.secondary],
]
return colors.reduce<{
const inferred = colors.reduce<{
byHue: Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
byToken: Partial<Record<V1HueToken, ChromaticHue>>
}>(
@@ -207,6 +256,19 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
},
{ byHue: {}, byToken: {} },
)
return (
[
["accent", theme.accent],
["primary", theme.primary],
] as const
).reduce((result, [token, color]) => {
const nearest = inferHue(color, mode)
if (!nearest) return result
return {
byHue: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
byToken: { ...result.byToken, [token]: nearest.name },
}
}, inferred)
}
function inferHue(color: RGBA, mode: Mode) {
@@ -0,0 +1,634 @@
/** @jsxImportSource @opentui/solid */
import { afterAll, describe, expect, test } from "bun:test"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import type { LogLevel, LogSink } from "../../../src/context/log"
import { createApi, createFetch } from "../../fixture/tui-client"
const packageRoot = process.env.OPENCODE_TUI_ROOT
const contextModule = packageRoot
? await import(`${packageRoot}/src/context/client.tsx`)
: await import("../../../src/context/client")
const environmentModule = packageRoot
? await import(`${packageRoot}/test/fixture/tui-environment.tsx`)
: await import("../../fixture/tui-environment")
const { ClientProvider, useClient } = contextModule as typeof import("../../../src/context/client")
const { TestTuiContexts } = environmentModule as typeof import("../../fixture/tui-environment")
type Client = ReturnType<typeof useClient>
type Service = {
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
restart: () => Promise<void>
}
type Observation = {
scenario: string
value: unknown
}
const observations: Observation[] = []
const connected = { id: "evt_connected", type: "server.connected", data: {} } as OpenCodeEvent
afterAll(async () => {
const output = process.env.CLIENT_BEHAVIOR_OUTPUT
if (output) await Bun.write(output, `${JSON.stringify(observations, null, 2)}\n`)
})
function observe(scenario: string, value: unknown) {
observations.push({ scenario, value })
}
function normalizeError(error: unknown) {
if (error instanceof Error) return `${error.name}:${error.message}`
return String(error)
}
function history(client: Client) {
return client.connection.internal.history().map((event) => ({
status: event.data.status,
attempt: event.data.attempt,
error: event.data.error,
}))
}
async function waitFor(check: () => boolean, timeout = 3_000) {
const started = Date.now()
while (!check()) {
if (Date.now() - started > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(5)
}
}
function event(type: "vcs" | "update" | "rename", suffix: string): OpenCodeEvent {
if (type === "vcs") {
return {
id: `evt_vcs_${suffix}`,
created: 1,
type: "vcs.branch.updated",
location: { directory: "/tmp/project" },
data: { branch: suffix },
}
}
if (type === "update") {
return {
id: `evt_update_${suffix}`,
created: 2,
type: "installation.update-available",
data: { version: suffix },
}
}
return {
id: `evt_rename_${suffix}`,
created: 3,
type: "session.renamed",
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
location: { directory: "/tmp/project" },
data: { sessionID: "ses_test", title: suffix },
}
}
function createStream(options?: { first?: OpenCodeEvent; closeBeforeHandshake?: boolean }) {
const encoder = new TextEncoder()
const controllers = new Set<ReadableStreamDefaultController<Uint8Array>>()
const requests: Request[] = []
const aborts: string[] = []
let cancellations = 0
function response(request: Request) {
requests.push(request)
request.signal.addEventListener("abort", () => aborts.push(normalizeError(request.signal.reason)), { once: true })
let current: ReadableStreamDefaultController<Uint8Array> | undefined
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
current = controller
controllers.add(controller)
if (options?.closeBeforeHandshake) {
controllers.delete(controller)
controller.close()
return
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(options?.first ?? connected)}\n\n`))
},
cancel() {
cancellations += 1
if (current) controllers.delete(current)
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
return {
response,
emit(value: OpenCodeEvent) {
const chunk = encoder.encode(`data: ${JSON.stringify(value)}\n\n`)
for (const controller of controllers) controller.enqueue(chunk)
},
raw(value: string) {
const chunk = encoder.encode(value)
for (const controller of controllers) controller.enqueue(chunk)
},
close() {
for (const controller of [...controllers]) {
controllers.delete(controller)
controller.close()
}
},
fail(message: string) {
for (const controller of [...controllers]) {
controllers.delete(controller)
controller.error(new Error(message))
}
},
snapshot() {
return {
requests: requests.length,
requestAborted: requests.map((request) => request.signal.aborted),
aborts,
cancellations,
active: controllers.size,
}
},
}
}
function apiFor(stream: ReturnType<typeof createStream>) {
return createApi(
createFetch((url, request) => {
if (url.pathname === "/api/event") return stream.response(request)
}).fetch,
)
}
async function mount(input: {
api: OpenCodeClient
service?: Service
throwOn?: OpenCodeEvent["type"]
}) {
const seen: Array<{ type: string; status: string }> = []
const typed: string[] = []
const logs: Array<{ level: LogLevel; message: string; tags: Record<string, unknown> }> = []
let initialStatus = ""
let client!: Client
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
const log: LogSink = (level, message, tags) => {
logs.push({ level, message, tags: { ...tags } })
}
const app = await testRender(() => (
<TestTuiContexts log={log}>
<ClientProvider api={input.api} service={input.service}>
<Probe
onReady={(value) => {
client = value
initialStatus = value.connection.status()
ready()
}}
onEvent={(value) => {
seen.push({ type: value.type, status: client.connection.status() })
if (value.type === input.throwOn) throw new Error(`listener failed for ${value.type}`)
}}
onBranch={(branch) => typed.push(branch)}
/>
</ClientProvider>
</TestTuiContexts>
))
await mounted
return { app, client, initialStatus, seen, typed, logs }
}
function Probe(props: {
onReady: (client: Client) => void
onEvent: (event: OpenCodeEvent) => void
onBranch: (branch: string) => void
}) {
const client = useClient()
onMount(() => {
client.event.listen(({ details }) => props.onEvent(details))
client.event.on("vcs.branch.updated", (value) => props.onBranch(value.data.branch ?? ""))
props.onReady(client)
})
return <box />
}
describe("ClientProvider connection characterization", () => {
test("records handshake ordering, event delivery, logging, and active-stream cleanup", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.emit(event("vcs", "main"))
stream.emit(event("rename", "renamed"))
stream.emit(event("update", "2.0.0"))
await waitFor(() => setup.seen.length === 4)
observe("healthy.connected", {
initialStatus: setup.initialStatus,
finalStatus: setup.client.connection.status(),
seen: setup.seen,
typed: setup.typed,
logs: setup.logs,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
await waitFor(() => stream.snapshot().requestAborted[0] === true)
await Bun.sleep(20)
observe("healthy.cleanup", {
history: history(setup.client),
stream: stream.snapshot(),
})
expect(setup.seen.map((item) => item.type)).toEqual([
"server.connected",
"vcs.branch.updated",
"session.renamed",
"installation.update-available",
])
expect(setup.seen.map((item) => item.status)).toEqual(["connecting", "connected", "connected", "connected"])
expect(setup.logs.filter((item) => item.message === "event")).toHaveLength(1)
})
test("records an invalid first event", async () => {
const stream = createStream({ first: event("vcs", "invalid-handshake") })
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("handshake.invalid", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Event stream did not start with server.connected")
})
test("records EOF before the handshake", async () => {
const stream = createStream({ closeBeforeHandshake: true })
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("handshake.eof", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Event stream disconnected")
})
test("records a fetch failure before the handshake", async () => {
const calls = createFetch((url) => {
if (url.pathname === "/api/event") throw new Error("network unavailable")
return undefined
})
const setup = await mount({ api: createApi(calls.fetch) })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("handshake.fetch-error", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
logs: setup.logs,
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Transport")
})
test("records the initial connection timeout and request cancellation", async () => {
const requests: Request[] = []
const calls = createFetch((url, request) => {
if (url.pathname !== "/api/event") return
requests.push(request)
return new Promise<Response>((_, reject) => {
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
})
})
const setup = await mount({ api: createApi(calls.fetch) })
await waitFor(() => setup.client.connection.status() === "reconnecting", 3_000)
observe("handshake.timeout", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
requestCount: requests.length,
requestAborted: requests.map((request) => request.signal.aborted),
abortReasons: requests.map((request) => normalizeError(request.signal.reason)),
history: history(setup.client),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Transport")
})
test("records static transport reconnection after a connected stream closes", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => stream.snapshot().requests === 2, 2_000)
await waitFor(() => setup.client.connection.status() === "connected")
observe("reconnect.static", {
status: setup.client.connection.status(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
logs: setup.logs.filter((item) => item.message !== "event"),
})
setup.app.renderer.destroy()
expect(setup.seen.map((item) => item.type)).toEqual(["server.connected", "server.connected"])
})
test("records immediate managed-service replacement", async () => {
const initial = createStream()
const replacement = createStream()
const replacementApi = apiFor(replacement)
const reconnectSignals: boolean[] = []
const service: Service = {
reconnect(signal) {
reconnectSignals.push(signal.aborted)
return Promise.resolve({ api: replacementApi })
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apiFor(initial), service })
await waitFor(() => setup.client.connection.status() === "connected")
initial.close()
await waitFor(() => replacement.snapshot().requests === 1)
await waitFor(() => setup.client.connection.status() === "connected")
replacement.emit(event("vcs", "replacement"))
await waitFor(() => setup.typed.includes("replacement"))
observe("reconnect.managed-replacement", {
status: setup.client.connection.status(),
apiReplaced: setup.client.api === replacementApi,
reconnectSignals,
seen: setup.seen,
typed: setup.typed,
history: history(setup.client),
initial: initial.snapshot(),
replacement: replacement.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.api).toBe(replacementApi)
})
test("records managed-service resolution failure and delayed retry", async () => {
const stream = createStream()
let reconnects = 0
const service: Service = {
reconnect() {
reconnects += 1
return Promise.reject(new Error("service unavailable"))
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apiFor(stream), service })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => stream.snapshot().requests === 2, 2_000)
await waitFor(() => setup.client.connection.status() === "connected")
observe("reconnect.managed-failure", {
reconnects,
status: setup.client.connection.status(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
resolutionLogs: setup.logs.filter((item) => item.message === "server resolution failed"),
})
setup.app.renderer.destroy()
expect(reconnects).toBe(1)
})
test("records cleanup while the initial fetch is pending", async () => {
const requests: Request[] = []
const aborts: string[] = []
const calls = createFetch((url, request) => {
if (url.pathname !== "/api/event") return
requests.push(request)
return new Promise<Response>((_, reject) => {
request.signal.addEventListener(
"abort",
() => {
aborts.push(normalizeError(request.signal.reason))
reject(request.signal.reason)
},
{ once: true },
)
})
})
const setup = await mount({ api: createApi(calls.fetch) })
await waitFor(() => requests.length === 1)
setup.app.renderer.destroy()
await waitFor(() => requests[0].signal.aborted)
await Bun.sleep(20)
observe("cleanup.pending-handshake", {
status: setup.client.connection.status(),
requestAborted: requests[0].signal.aborted,
aborts,
history: history(setup.client),
logs: setup.logs,
})
expect(history(setup.client).map((item) => item.status)).toEqual(["connecting"])
})
test("records an event listener failure as a connection failure", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream), throwOn: "vcs.branch.updated" })
await waitFor(() => setup.client.connection.status() === "connected")
stream.emit(event("vcs", "throws"))
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("listener.failure", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
typed: setup.typed,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("listener failed for vcs.branch.updated")
})
test("records stream reader failure after connection", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.fail("reader exploded")
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("stream.reader-failure", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Transport")
})
test("records malformed SSE data after connection", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.raw("data: not-json\n\n")
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("stream.malformed-data", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("MalformedResponse")
})
test("records a server.connected listener failure before connected state publication", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream), throwOn: "server.connected" })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("listener.connected-failure", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(history(setup.client).map((item) => item.status)).toEqual(["connecting", "connected", "disconnected"])
})
test("records cleanup during static reconnect backoff", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => setup.client.connection.status() === "reconnecting")
setup.app.renderer.destroy()
await Bun.sleep(1_050)
observe("cleanup.reconnect-backoff", {
status: setup.client.connection.status(),
history: history(setup.client),
stream: stream.snapshot(),
})
expect(stream.snapshot().requests).toBe(1)
})
test("records cleanup during managed-service resolution", async () => {
const stream = createStream()
let resolutionStarted = false
let resolutionAborted = false
const service: Service = {
reconnect(signal) {
resolutionStarted = true
return new Promise((_, reject) => {
signal.addEventListener(
"abort",
() => {
resolutionAborted = true
reject(signal.reason)
},
{ once: true },
)
})
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apiFor(stream), service })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => resolutionStarted)
setup.app.renderer.destroy()
await waitFor(() => resolutionAborted)
await Bun.sleep(20)
observe("cleanup.service-resolution", {
resolutionStarted,
resolutionAborted,
status: setup.client.connection.status(),
history: history(setup.client),
stream: stream.snapshot(),
logs: setup.logs,
})
expect(resolutionAborted).toBe(true)
})
test("records attempt reset after a stable connection", async () => {
const streams = [createStream(), createStream(), createStream()]
const apis = streams.map(apiFor)
let reconnects = 0
const service: Service = {
reconnect() {
const api = apis[Math.min(reconnects + 1, apis.length - 1)]
reconnects += 1
return Promise.resolve({ api })
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apis[0], service })
await waitFor(() => setup.client.connection.status() === "connected")
streams[0].close()
await waitFor(() => streams[1].snapshot().requests === 1)
streams[1].close()
await waitFor(() => streams[2].snapshot().requests === 1)
await Bun.sleep(1_050)
streams[2].close()
await waitFor(() => reconnects === 3)
observe("reconnect.stable-reset", {
reconnects,
status: setup.client.connection.status(),
history: history(setup.client),
streams: streams.map((stream) => stream.snapshot()),
})
setup.app.renderer.destroy()
expect(history(setup.client).filter((item) => item.status === "disconnected").map((item) => item.attempt)).toEqual([
1, 2, 1,
])
})
})
+46 -11
View File
@@ -5,7 +5,7 @@ import { selectThemeMode, themeModes } from "../../../src/theme/v2/select"
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults"
test("migrates resolved V1 modes into literal V2 tokens", () => {
test("migrates resolved V1 modes into V2 tokens", () => {
const migrated = migrateV1(DEFAULT_THEMES.opencode)
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
@@ -14,13 +14,8 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(migrated.standalone).toBeTrue()
expect(migrated.light.categorical?.length).toBeGreaterThan(0)
expect(migrated.dark.categorical?.length).toBeGreaterThan(0)
expect(migrated.light.hue?.accent).toBeObject()
expect(migrated.light.hue?.interactive).toBeObject()
if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
throw new Error("Expected concrete accent and interactive scales")
}
expect(migrated.light.hue.accent[800]).toBe(hex(legacy.accent))
expect(migrated.light.hue.interactive[800]).toBe(hex(legacy.primary))
expect(migrated.light.hue?.accent).toMatch(/^\$hue\.[^.]+$/)
expect(migrated.light.hue?.interactive).toMatch(/^\$hue\.[^.]+$/)
expect(migrated.light.text?.default).toBe("$hue.neutral.800")
expect(migrated.light.text?.subdued).toBe("$hue.neutral.600")
expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
@@ -32,9 +27,6 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.600")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.light.background?.action?.primary?.$selected).toBe("transparent")
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
expect(resolved.background.surface.offset.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundElement.toInts())
expect(resolved.background.formfield.selected.toInts()).toEqual(legacy.background.toInts())
@@ -54,6 +46,22 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
})
test("references generated hues from matching token colors", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.border = source.theme.primary
source.theme.borderActive = source.theme.accent
source.theme.syntaxKeyword = source.theme.error
source.theme.markdownEmph = "#123456"
const migrated = migrateV1(source)
if (!migrated.light) throw new Error("Expected light mode")
expect(migrated.light.border?.default).toBe("$hue.interactive.800")
expect(migrated.light.scrollbar?.default).toBe("$hue.accent.800")
expect(migrated.light.syntax?.keyword).toMatch(/^\$hue\.[^.]+\.800$/)
expect(migrated.light.markdown?.emphasis).toBe("#123456")
})
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const ambiguous = { light: "#808080", dark: "#808080" }
@@ -108,6 +116,33 @@ test("orders categorical hues by V1 semantic color mapping", () => {
expect(migrateV1(source).light?.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
})
test("gives accent and primary ownership of their inferred hues", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.success = DEFAULT_THEME.light.hue.orange[300]
source.theme.accent = DEFAULT_THEME.light.hue.orange[400]
source.theme.info = DEFAULT_THEME.light.hue.blue[300]
source.theme.primary = DEFAULT_THEME.light.hue.blue[400]
const migrated = migrateV1(source)
if (!migrated.light) throw new Error("Expected light mode")
const orange = migrated.light.hue?.orange
const blue = migrated.light.hue?.blue
if (typeof orange !== "object" || typeof blue !== "object") throw new Error("Expected concrete hue scales")
expect(orange[800]).toBe(source.theme.accent)
expect(blue[800]).toBe(source.theme.primary)
expect(migrated.light.hue?.accent).toBe("$hue.orange")
expect(migrated.light.hue?.interactive).toBe("$hue.blue")
source.theme.primary = DEFAULT_THEME.light.hue.orange[500]
const collisionMode = migrateV1(source).light
const collision = collisionMode?.hue?.orange
if (typeof collision !== "object") throw new Error("Expected concrete orange scale")
expect(collision[800]).toBe(source.theme.primary)
expect(collisionMode?.hue?.accent).toBe("$hue.orange")
expect(collisionMode?.hue?.interactive).toBe("$hue.orange")
})
test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.secondary = "transparent"