Merge remote-tracking branch 'origin/dev' into httpapi-codegen

# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/server/src/groups/session.ts
This commit is contained in:
Kit Langton
2026-06-23 12:01:37 -04:00
305 changed files with 6809 additions and 5744 deletions
+1 -1
View File
@@ -266,7 +266,7 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
// For shell tools, surface the actual command as the title so it stays visible
// before output lands; non-shell tools keep their model-provided title.
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName
if (isShell(toolName)) return shellCommand(input) ?? fallback ?? toolName
return fallback || toolName
}
+7 -5
View File
@@ -28,9 +28,9 @@ import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Reference } from "@opencode-ai/core/reference"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
export const Info = Schema.Struct({
name: Schema.String,
@@ -99,10 +99,12 @@ export const layer = Layer.effect(
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const referenceDirs = yield* Effect.gen(function* () {
yield* (yield* PluginBoot.Service).wait()
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length
? yield* Effect.gen(function* () {
yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference"))
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
: []
const whitelistedDirs = [
Truncate.GLOB,
path.join(Global.Path.tmp, "*"),
@@ -3,7 +3,6 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { effectCmd } from "../../effect-cmd"
@@ -13,7 +12,6 @@ export const V2Command = effectCmd({
instance: false,
handler: () =>
Effect.gen(function* () {
yield* PluginBoot.Service.use((service) => service.wait())
const catalog = yield* Catalog.Service
const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id))
const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id))
+6 -9
View File
@@ -623,20 +623,18 @@ function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
function scrollBashStart(p: ToolProps<typeof BashTool>): string {
const cmd = p.input.command ?? ""
const desc = p.input.description || "Shell"
const wd = p.input.workdir ?? ""
const dir = wd && wd !== "." ? toolPath(wd) : ""
if (cmd && desc === "Shell" && !dir) {
const formatted = wd && wd !== "." ? toolPath(wd) : ""
const dir = formatted === "." ? "" : formatted
if (cmd && !dir) {
return `$ ${cmd}`
}
const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc
if (!cmd) {
return `# ${title}`
return dir ? `# Running in ${dir}` : ""
}
return `# ${title}\n$ ${cmd}`
return `# Running in ${dir}\n$ ${cmd}`
}
function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
@@ -968,11 +966,10 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo {
}
function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
const title = p.input.description || "Shell command"
const cmd = p.input.command || ""
return {
icon: "#",
title,
title: "Shell command",
lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`),
}
}
+9
View File
@@ -13,6 +13,13 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy
Heap.start()
const onUnhandledRejection = (_error: unknown) => {}
const onUncaughtException = (_error: Error) => {}
process.on("unhandledRejection", onUnhandledRejection)
process.on("uncaughtException", onUncaughtException)
// Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
@@ -65,6 +72,8 @@ export const rpc = {
async shutdown() {
await InstanceRuntime.disposeAllInstances()
if (server) await server.stop(true)
process.off("unhandledRejection", onUnhandledRejection)
process.off("uncaughtException", onUncaughtException)
},
}
+5 -1
View File
@@ -86,6 +86,7 @@ export function fetch<T extends { name: string }>(
client: Client,
list: (client: Client) => Promise<T[]>,
label: string,
key?: (item: T) => string,
) {
return Effect.tryPromise({
try: () => list(client),
@@ -100,7 +101,10 @@ export function fetch<T extends { name: string }>(
Effect.map((items) => {
const sanitizedClient = sanitize(clientName)
return Object.fromEntries(
items.map((item) => [sanitizedClient + ":" + sanitize(item.name), { ...item, client: clientName }]),
items.map((item) => [
key ? clientName + ":" + key(item) : sanitizedClient + ":" + sanitize(item.name),
{ ...item, client: clientName },
]),
)
}),
Effect.orElseSucceed(() => undefined),
+15 -4
View File
@@ -161,7 +161,7 @@ export interface Interface {
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly tools: () => Effect.Effect<Record<string, Tool>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
@@ -654,17 +654,22 @@ export const layer = Layer.effect(
s: State,
listFn: (c: Client, timeout?: number) => Promise<T[]>,
label: string,
key?: (item: T) => string,
targetClientName?: string,
) {
return Effect.gen(function* () {
const cfg = yield* cfgSvc.get()
return yield* Effect.forEach(
Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
Object.entries(s.clients).filter(
([name]) => s.status[name]?.status === "connected" && (!targetClientName || name === targetClientName),
),
([clientName, client]) =>
McpCatalog.fetch(
clientName,
client,
(c) => listFn(c, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)),
label,
key,
).pipe(Effect.map((items) => Object.entries(items ?? {}))),
{ concurrency: "unbounded" },
).pipe(Effect.map((results) => Object.fromEntries<T & { client: string }>(results.flat())))
@@ -675,8 +680,14 @@ export const layer = Layer.effect(
return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.prompts, "prompts")
})
const resources = Effect.fn("MCP.resources")(function* () {
return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.resources, "resources")
const resources = Effect.fn("MCP.resources")(function* (clientName?: string) {
return yield* collectFromConnected(
yield* InstanceState.get(state),
McpCatalog.resources,
"resources",
(resource) => resource.uri,
clientName,
)
})
const withClient = Effect.fnUntraced(function* <A>(
+2 -1
View File
@@ -214,9 +214,10 @@ export function merge(...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule[]
export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<string> {
const edits = ["edit", "write", "apply_patch"]
const reads = ["list_mcp_resources", "read_mcp_resource"]
return new Set(
tools.filter((tool) => {
const permission = edits.includes(tool) ? "edit" : tool
const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
+57 -7
View File
@@ -66,6 +66,14 @@ globalThis.AI_SDK_LOG_WARNINGS = false
const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info)
const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part)
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
"application/pdf",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
])
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
@@ -77,6 +85,18 @@ IMPORTANT:
const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
function mcpResourceBase64Size(value: string) {
const trimmed = value.replace(/\s/g, "")
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
}
function formatMcpResourceBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
return `${Math.ceil(value / (1024 * 1024))} MB`
}
function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
// cleanup() marks abandoned tool_use blocks this way after retries/aborts.
// They are not pending work and must not trigger an assistant-prefill request.
@@ -542,7 +562,7 @@ export const layer = Layer.effect(
time: { ...part.state.time, end: completed },
input: part.state.input,
title: "",
metadata: { output, description: "" },
metadata: { output },
output,
}
yield* sessions.updatePart(part)
@@ -569,7 +589,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
output += chunk
if (part.state.status === "running") {
part.state.metadata = { output, description: "" }
part.state.metadata = { output }
yield* sessions.updatePart(part)
}
}),
@@ -731,7 +751,8 @@ export const layer = Layer.effect(
if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)
const items = Array.isArray(content.contents) ? content.contents : [content.contents]
for (const c of items) {
if ("text" in c && c.text) {
if (!c || typeof c !== "object") continue
if ("text" in c && typeof c.text === "string" && c.text) {
pieces.push({
messageID: info.id,
sessionID: input.sessionID,
@@ -739,18 +760,47 @@ export const layer = Layer.effect(
synthetic: true,
text: c.text,
})
} else if ("blob" in c && c.blob) {
const mime = "mimeType" in c ? c.mimeType : part.mime
} else if ("blob" in c && typeof c.blob === "string" && c.blob) {
const mime = "mimeType" in c && typeof c.mimeType === "string" ? c.mimeType : part.mime
const filename = "uri" in c && typeof c.uri === "string" ? c.uri : part.filename
const size = mcpResourceBase64Size(c.blob)
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
pieces.push({
messageID: info.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) is not a supported attachment type]`,
})
continue
}
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
pieces.push({
messageID: info.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) exceeds ${formatMcpResourceBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
})
continue
}
pieces.push({
messageID: info.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: `[Binary content: ${mime}]`,
text: `[Binary MCP resource attached: ${filename ?? uri} (${mime})]`,
})
pieces.push({
messageID: info.id,
sessionID: input.sessionID,
type: "file",
mime,
filename,
url: `data:${mime};base64,${c.blob}`,
})
}
}
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
} else {
const error = Cause.squash(exit.cause)
yield* Effect.logError("failed to read MCP resource", { error, clientName, uri })
-2
View File
@@ -19,7 +19,6 @@ import { Skill } from "@/skill"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Reference } from "@opencode-ai/core/reference"
export function provider(model: Provider.Model) {
@@ -55,7 +54,6 @@ export const layer = Layer.effect(
environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) {
const ctx = yield* InstanceState.context
const references = yield* Effect.gen(function* () {
yield* (yield* PluginBoot.Service).wait()
return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
return [
+287 -2
View File
@@ -20,6 +20,18 @@ import { PartID } from "./schema"
import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { isRecord } from "@/util/record"
const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources"
const READ_MCP_RESOURCE_TOOL = "read_mcp_resource"
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
"application/pdf",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
])
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
agent: Agent.Info
@@ -114,6 +126,175 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
}
const hasMcpResourceServer = Object.values(yield* mcp.clients()).some(
(client) => !!client.getServerCapabilities()?.resources,
)
if (hasMcpResourceServer) {
tools[LIST_MCP_RESOURCES_TOOL] = tool({
description:
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
inputSchema: jsonSchema(
ProviderTransform.schema(input.model, {
type: "object",
properties: {
server: {
type: "string",
description: "Optional MCP server name. When omitted, lists resources from every connected server.",
},
},
additionalProperties: false,
}),
),
execute(args, opts) {
return run.promise(
Effect.gen(function* () {
const parsed = parseListMcpResourcesArgs(args)
const ctx = context(toRecord(args), opts)
const clients = yield* mcp.clients()
const resourceServers = Object.entries(clients)
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
.map((entry) => entry[0])
.sort((a, b) => a.localeCompare(b))
if (parsed.server && !resourceServers.includes(parsed.server)) {
throw new Error(
resourceServers.length === 0
? `MCP server "${parsed.server}" does not support resources`
: `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
)
}
const permissionPatterns = parsed.server
? [`mcp:${parsed.server}:*`]
: resourceServers.map((server) => `mcp:${server}:*`)
yield* plugin.trigger(
"tool.execute.before",
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: parsed.server ? { server: parsed.server } : {},
patterns: permissionPatterns,
always: permissionPatterns,
})
const resources = Object.values(yield* mcp.resources(parsed.server))
const filtered = resources
.filter((resource) => !parsed.server || resource.client === parsed.server)
.toSorted((a, b) =>
(a.client + "\u0000" + a.name + "\u0000" + a.uri).localeCompare(
b.client + "\u0000" + b.name + "\u0000" + b.uri,
),
)
const content = JSON.stringify({ resources: filtered.map(formatMcpResource) }, null, 2)
const truncated = yield* truncate.output(content, {}, input.agent)
const output = {
title: parsed.server ? `MCP resources: ${parsed.server}` : "MCP resources",
metadata: {
count: filtered.length,
servers: resourceServers,
...(parsed.server ? { server: parsed.server } : {}),
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
output: truncated.content,
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}),
)
},
})
tools[READ_MCP_RESOURCE_TOOL] = tool({
description:
"Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.",
inputSchema: jsonSchema(
ProviderTransform.schema(input.model, {
type: "object",
properties: {
server: {
type: "string",
description: "MCP server name exactly as returned by list_mcp_resources.",
},
uri: {
type: "string",
description: "Resource URI to read. Use the exact URI string returned by list_mcp_resources.",
},
},
required: ["server", "uri"],
additionalProperties: false,
}),
),
execute(args, opts) {
return run.promise(
Effect.gen(function* () {
const parsed = parseReadMcpResourceArgs(args)
const ctx = context(toRecord(args), opts)
const clients = yield* mcp.clients()
const client = clients[parsed.server]
if (!client) {
throw new Error(`MCP server "${parsed.server}" is not connected`)
}
if (!client.getServerCapabilities()?.resources) {
throw new Error(`MCP server "${parsed.server}" does not support resources`)
}
yield* plugin.trigger(
"tool.execute.before",
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: { server: parsed.server, uri: parsed.uri },
patterns: [`mcp:${parsed.server}:${parsed.uri}`],
always: [`mcp:${parsed.server}:*`],
})
const content = yield* mcp.readResource(parsed.server, parsed.uri)
if (!content) throw new Error(`Failed to read MCP resource: ${parsed.server}/${parsed.uri}`)
const formatted = formatMcpResourceContent(parsed.server, parsed.uri, content)
const truncated = yield* truncate.output(formatted.text, {}, input.agent)
const output = {
title: `MCP resource: ${parsed.uri}`,
metadata: {
server: parsed.server,
uri: parsed.uri,
contents: formatted.contents,
attachments: formatted.attachments.length,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
output: truncated.content,
attachments: formatted.attachments.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}),
)
},
})
}
for (const [key, item] of Object.entries(yield* mcp.tools())) {
const execute = item.execute
if (!execute) continue
@@ -163,10 +344,24 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const { resource } = contentItem
if (resource.text) textParts.push(resource.text)
if (resource.blob) {
const mime = resource.mimeType ?? "application/octet-stream"
const size = base64Size(resource.blob)
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
textParts.push(
`[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
)
continue
}
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
textParts.push(
`[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
)
continue
}
attachments.push({
type: "file",
mime: resource.mimeType ?? "application/octet-stream",
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
mime,
url: `data:${mime};base64,${resource.blob}`,
filename: resource.uri,
})
}
@@ -204,4 +399,94 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
return tools
})
function toRecord(value: unknown) {
if (isRecord(value)) return value
return {}
}
function parseListMcpResourcesArgs(value: unknown) {
const args = toRecord(value)
return { server: optionalString(args, "server") }
}
function parseReadMcpResourceArgs(value: unknown) {
const args = toRecord(value)
return { server: requiredString(args, "server"), uri: requiredString(args, "uri") }
}
function optionalString(args: Record<string, unknown>, key: string) {
const value = args[key]
if (value === undefined || value === null || value === "") return undefined
if (typeof value !== "string") throw new Error(`${key} must be a string`)
return value
}
function requiredString(args: Record<string, unknown>, key: string) {
const value = optionalString(args, key)
if (value) return value
throw new Error(`${key} is required`)
}
function formatMcpResource(resource: MCP.Resource) {
const result = Object.fromEntries(Object.entries(resource).filter((entry) => entry[0] !== "client"))
return { ...result, server: resource.client }
}
function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
const text: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
for (const item of items) {
const itemUri = typeof item.uri === "string" ? item.uri : uri
const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream"
if (typeof item.text === "string") {
text.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`)
continue
}
if (typeof item.blob === "string") {
const size = base64Size(item.blob)
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
text.push(
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
)
continue
}
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
text.push(
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
)
continue
}
text.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`)
attachments.push({
type: "file",
mime,
url: `data:${mime};base64,${item.blob}`,
filename: itemUri,
})
continue
}
text.push(`[MCP resource content without text or blob: ${itemUri}]`)
}
return {
contents: items.length,
attachments,
text: text.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`,
}
}
function base64Size(value: string) {
const trimmed = value.replace(/\s/g, "")
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
return `${Math.ceil(value / (1024 * 1024))} MB`
}
export * as SessionTools from "./tools"
+2 -14
View File
@@ -260,11 +260,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole
return tree
})
const ask = Effect.fn("ShellTool.ask")(function* (
ctx: Tool.Context,
scan: Scan,
input: { command: string; description: string },
) {
const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, input: { command: string }) {
if (scan.dirs.size > 0) {
const directories = Array.from(scan.dirs)
const globs = directories.map((dir) => {
@@ -277,7 +273,6 @@ const ask = Effect.fn("ShellTool.ask")(function* (
always: globs,
metadata: {
command: input.command,
description: input.description,
directories,
patterns: globs,
},
@@ -291,7 +286,6 @@ const ask = Effect.fn("ShellTool.ask")(function* (
always: Array.from(scan.always),
metadata: {
command: input.command,
description: input.description,
},
})
})
@@ -438,7 +432,6 @@ export const ShellTool = Tool.define(
cwd: string
env: NodeJS.ProcessEnv
timeout: number
description: string
},
ctx: Tool.Context,
) {
@@ -482,7 +475,6 @@ export const ShellTool = Tool.define(
yield* ctx.metadata({
metadata: {
output: "",
description: input.description,
},
})
@@ -523,7 +515,6 @@ export const ShellTool = Tool.define(
ctx.metadata({
metadata: {
output: last,
description: input.description,
},
}),
),
@@ -534,7 +525,6 @@ export const ShellTool = Tool.define(
return ctx.metadata({
metadata: {
output: last,
description: input.description,
},
})
}),
@@ -593,11 +583,10 @@ export const ShellTool = Tool.define(
output += "\n\n<shell_metadata>\n" + meta.join("\n") + "\n</shell_metadata>"
}
return {
title: input.description,
title: input.command,
metadata: {
output: last || preview(output),
exit: code,
description: input.description,
truncated: cut,
...(cut && file ? { outputPath: file } : {}),
},
@@ -646,7 +635,6 @@ export const ShellTool = Tool.define(
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
description: params.description,
},
ctx,
)
+3 -17
View File
@@ -7,30 +7,22 @@ import { ShellID } from "./id"
const PS = new Set(["powershell", "pwsh"])
const CMD = new Set(["cmd"])
const descriptions = {
bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
powershell:
'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp',
cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp',
}
export type Limits = {
maxLines: number
maxBytes: number
}
export function parameterSchema(description: string) {
export function parameterSchema() {
return Schema.Struct({
command: Schema.String.annotate({ description: "The command to execute" }),
timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }),
workdir: Schema.optional(Schema.String).annotate({
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
}),
description: Schema.String.annotate({ description }),
})
}
export const Parameters = parameterSchema(descriptions.bash)
export const Parameters = parameterSchema()
export type Parameters = Schema.Schema.Type<typeof Parameters>
function renderPrompt(template: string, values: Record<string, string>) {
@@ -103,7 +95,6 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
- Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
@@ -155,7 +146,6 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
- Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
@@ -205,7 +195,6 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching.
- Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
@@ -242,7 +231,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
gitCommandRestriction: "git commands",
createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.",
createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`,
parameterDescription: descriptions.cmd,
}
}
if (isPowerShell) {
@@ -264,7 +252,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
## Summary
- <1-3 bullet points>
'@`,
parameterDescription: descriptions.powershell,
}
}
return {
@@ -280,7 +267,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Summary
<1-3 bullet points>`,
parameterDescription: descriptions.bash,
}
}
@@ -300,7 +286,7 @@ export function render(name: string, platform: NodeJS.Platform, limits: Limits,
createPrInstruction: selected.createPrInstruction,
createPrExample: selected.createPrExample,
}),
parameters: parameterSchema(selected.parameterDescription),
parameters: parameterSchema(),
}
}
@@ -13,7 +13,6 @@
// version (changes per release), so we'd snapshot a moving target.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { EOL } from "os"
import { cliIt } from "../../lib/cli-process"
import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot"
@@ -101,7 +100,7 @@ describe("opencode CLI help-text snapshots", () => {
Effect.gen(function* () {
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
expect(topLevel.exitCode).toBe(0)
expect(topLevel.stderr.endsWith(EOL)).toBe(true)
expect(topLevel.stderr.endsWith("\n")).toBe(true)
expect(topLevel.stderr).toContain("--mini")
expect(topLevel.stderr).not.toContain("--thinking")
expect(topLevel.stderr).not.toContain("--variant")
@@ -589,6 +589,38 @@ test("coalesces same-line tool progress into one snapshot", async () => {
}
})
test("omits the current directory from bash titles", async () => {
const out = await setup()
try {
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
command: "pwd",
workdir: process.cwd(),
},
time: { start: 1 },
},
}),
)
const commits = claim(out.renderer)
try {
expect(render(commits)).toContain("$ pwd")
expect(render(commits)).not.toContain("Running in .")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
test("renders completed bash output with one blank line after the command and before the next group", async () => {
const out = await setup()
@@ -615,7 +647,6 @@ test("renders completed bash output with one blank line after the command and be
input: {
command: "git status",
workdir: "/tmp/demo",
description: "Show git status",
},
time: { start: 1 },
},
@@ -633,7 +664,6 @@ test("renders completed bash output with one blank line after the command and be
input: {
command: "git status",
workdir: "/tmp/demo",
description: "Show git status",
},
time: { start: 1, end: 2 },
},
@@ -645,6 +675,7 @@ test("renders completed bash output with one blank line after the command and be
take()
const output = lines.join("\n")
expect(output).toContain("# Running in /tmp/demo\n$ git status")
expect(output).toContain("$ git status\n\nOn branch demo")
expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1")
expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1")
@@ -677,7 +708,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu
input: {
command: "pwd; ls -la",
workdir: "/tmp/demo",
description: "Lists current directory files",
},
time: { start: 1 },
},
@@ -695,7 +725,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu
input: {
command: "pwd; ls -la",
workdir: "/tmp/demo",
description: "Lists current directory files",
},
output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"),
title: "pwd; ls -la",
@@ -755,7 +784,6 @@ test("does not double-space before completed bash output when inline tool header
input: {
command: "ls",
workdir: "src/cli/cmd/run",
description: "Lists files in run directory",
},
time: { start: 1 },
},
@@ -805,7 +833,6 @@ test("does not double-space before completed bash output when inline tool header
input: {
command: "ls",
workdir: "src/cli/cmd/run",
description: "Lists files in run directory",
},
output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"),
title: "ls",
@@ -435,7 +435,6 @@ describe("run session data", () => {
title: "",
metadata: {
output: "/tmp/demo\n",
description: "",
},
time: { start: 1, end: 2 },
},
@@ -490,7 +489,6 @@ describe("run session data", () => {
title: "",
metadata: {
output: "/tmp/demo\n",
description: "",
},
time: { start: 1, end: 2 },
},
@@ -238,7 +238,6 @@ function shellAssistantMessage(id: string, parentID: string): SessionMessages[nu
title: "",
metadata: {
output: "account.ts\n",
description: "",
},
time: {
start: 200,
+21 -9
View File
@@ -20,7 +20,7 @@
import { test, type TestOptions } from "bun:test"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppProcess } from "@opencode-ai/core/process"
import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Stream } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import path from "node:path"
@@ -192,9 +192,12 @@ export function withCliFixture<A, E>(
const fs = yield* FSUtil.Service
const appProc = yield* AppProcess.Service
// FileSystem.makeTempDirectoryScoped handles both creation and scope-tied
// cleanup — replaces the old mkdir + addFinalizer pair.
const home = yield* fs.makeTempDirectoryScoped({ prefix: "oc-cli-" })
const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" })
yield* Effect.addFinalizer(() =>
fs
.remove(home, { recursive: true })
.pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore),
)
const configJson = JSON.stringify(testProviderConfig(llm.url))
const env = isolatedEnv(home, configJson)
@@ -237,8 +240,8 @@ export function withCliFixture<A, E>(
)
return {
exitCode: result.exitCode,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
stdout: normalizeLines(result.stdout.toString()),
stderr: normalizeLines(result.stderr.toString()),
durationMs: Date.now() - start,
}
})
@@ -299,8 +302,8 @@ export function withCliFixture<A, E>(
interrupt: () => proc.kill("SIGINT"),
result: Effect.promise(async () => ({
exitCode: await proc.exited,
stdout: await stdout,
stderr: await stderr,
stdout: normalizeLines(await stdout),
stderr: normalizeLines(await stderr),
durationMs: Date.now() - start,
})),
} satisfies RunHandle
@@ -479,6 +482,10 @@ function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
.map((line) => JSON.parse(line) as Record<string, unknown>)
}
function normalizeLines(value: string) {
return value.replaceAll("\r\n", "\n")
}
// Convenience for the common assertion pattern. Dumps stderr/stdout when
// the exit code doesn't match — saves debugging time on CI failures.
function expectExit(result: RunResult, expected: number, label = "opencode") {
@@ -513,5 +520,10 @@ export const cliIt = {
name: string,
body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
opts?: number | TestOptions,
) => test.concurrent(name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts),
) =>
(process.platform === "win32" ? test : test.concurrent)(
name,
() => Effect.runPromise(Effect.scoped(withCliFixture(body))),
opts,
),
}
+10 -7
View File
@@ -361,7 +361,7 @@ it.instance(
expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"])
expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
expect(serverState.listToolsCalls).toBe(2)
expect(serverState.listPromptsCalls).toBe(2)
expect(serverState.listResourcesCalls).toBe(2)
@@ -796,7 +796,10 @@ it.instance(
Effect.gen(function* () {
lastCreatedClientName = "resource-server"
const serverState = getOrCreateClientState("resource-server")
serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
serverState.resources = [
{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" },
{ name: "my-resource", uri: "ui://component-state", description: "A second resource with same name" },
]
yield* mcp.add("resource-server", {
type: "local",
@@ -804,10 +807,10 @@ it.instance(
})
const resources = yield* mcp.resources()
expect(Object.keys(resources).length).toBe(1)
const key = Object.keys(resources)[0]
expect(key).toContain("resource-server")
expect(key).toContain("my-resource")
expect(Object.keys(resources)).toEqual([
"resource-server:file:///test.txt",
"resource-server:ui://component-state",
])
}),
),
{
@@ -863,7 +866,7 @@ it.instance(
expect(statusName(result.status, "resource-only-server")).toBe("connected")
expect(serverState.listToolsCalls).toBe(0)
expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs"])
expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs://readme"])
expect(serverState.listResourcesCalls).toBe(1)
expect(serverState.listPromptsCalls).toBe(0)
}),
@@ -269,7 +269,7 @@ mcpTest.instance(
const result = yield* mcp.authenticate("test-oauth-resources")
expect(result.status).toBe("connected")
expect(listToolsCalls).toBe(0)
expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs"])
expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs://readme"])
}),
),
{ config: config("test-oauth-resources") },
@@ -1,10 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Context } from "effect"
import { Context, Effect } from "effect"
import path from "path"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { pollWithTimeout } from "../lib/effect"
const context = Context.empty() as Context.Context<unknown>
@@ -55,17 +56,26 @@ describe("file HttpApi", () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(path.join(tmp.path, "hello.txt"), "needle")
const [text, files, symbols] = await Promise.all([
const [text, symbols] = await Promise.all([
request(FilePaths.findText, tmp.path, { pattern: "needle" }),
request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }),
request(FilePaths.findSymbol, tmp.path, { query: "hello" }),
])
const files = await Effect.runPromise(
pollWithTimeout(
Effect.promise(async () => {
const response = await request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" })
const body = await response.json()
return body.includes("hello.txt") ? { response, body } : undefined
}),
"file search index was not ready",
),
)
expect(text.status).toBe(200)
expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 }))
expect(files.status).toBe(200)
expect(await files.json()).toContain("hello.txt")
expect(files.response.status).toBe(200)
expect(files.body).toContain("hello.txt")
expect(symbols.status).toBe(200)
expect(await symbols.json()).toEqual([])
@@ -4,6 +4,8 @@ import { Server } from "../../src/server/server"
import { Global } from "@opencode-ai/core/global"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Effect } from "effect"
import { pollWithTimeout } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
@@ -24,12 +26,19 @@ describe("reference HttpApi", () => {
},
})
const response = await Server.Default().app.request("/api/reference", {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
const body = await response.json()
const body = await Effect.runPromise(
pollWithTimeout(
Effect.promise(async () => {
const response = await Server.Default().app.request("/api/reference", {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
const body = await response.json()
return body.data.length === 0 ? undefined : body
}),
"references were not loaded",
),
)
expect(body).toMatchObject({ location: { directory: tmp.path } })
expect(body.data).toEqual([
{
@@ -22,7 +22,7 @@ import { TestLLMServer } from "../lib/llm-server"
import path from "path"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -389,7 +389,12 @@ describe("HttpApi SDK", () => {
workspaceID,
onRequest: (value) => (request = value),
})
const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" }))
const found = yield* pollWithTimeout(
call(() => sdk.v2.fs.find({ query: "hello", type: "file" })).pipe(
Effect.map((result) => (result.data?.data.length ? result : undefined)),
),
"SDK file search index was not ready",
)
const url = new URL(request!.url)
expect(found.response.status).toBe(200)
@@ -1250,7 +1250,7 @@ describe("session.compaction.process", () => {
})
.pipe(Effect.forkChild)
yield* Deferred.await(ready).pipe(Effect.timeout("1 second"))
yield* Deferred.await(ready).pipe(Effect.timeout("5 seconds"))
const start = Date.now()
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
@@ -1263,6 +1263,7 @@ describe("session.compaction.process", () => {
}).pipe(withCompaction({ llm: stub.layer }))
},
{ git: true },
{ timeout: 10_000 },
)
itCompaction.instance(
+193 -211
View File
@@ -994,56 +994,52 @@ it.instance(
// Cancel semantics
it.instance(
"cancel interrupts loop and resolves with an assistant message",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* seed(chat.id)
it.instance("cancel interrupts loop and resolves with an assistant message", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* seed(chat.id)
yield* llm.hang
yield* llm.hang
yield* user(chat.id, "more")
yield* user(chat.id, "more")
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
yield* prompt.cancel(chat.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
expect(exit.value.info.role).toBe("assistant")
}
}),
3_000,
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
yield* waitForBusy(chat.id)
yield* prompt.cancel(chat.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
expect(exit.value.info.role).toBe("assistant")
}
}),
)
it.instance(
"cancel records MessageAbortedError on interrupted process",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.hang
yield* user(chat.id, "hello")
it.instance("cancel records MessageAbortedError on interrupted process", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.hang
yield* user(chat.id, "hello")
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
yield* prompt.cancel(chat.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
const info = exit.value.info
if (info.role === "assistant") {
expect(info.error?.name).toBe("MessageAbortedError")
}
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
yield* waitForBusy(chat.id)
yield* prompt.cancel(chat.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
const info = exit.value.info
if (info.role === "assistant") {
expect(info.error?.name).toBe("MessageAbortedError")
}
}),
3_000,
}
}),
)
raceNoLLMServer.instance(
@@ -1242,7 +1238,7 @@ it.instance(
}
}),
{ git: true },
3_000,
10_000,
)
// Queue semantics
@@ -1262,124 +1258,115 @@ noLLMServer.instance("concurrent loop callers get same result", () =>
}),
)
it.instance(
"concurrent loop callers all receive same error result",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
it.instance("concurrent loop callers all receive same error result", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.fail("boom")
yield* user(chat.id, "hello")
yield* llm.fail("boom")
yield* user(chat.id, "hello")
const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
concurrency: "unbounded",
const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
concurrency: "unbounded",
})
expect(a.info.id).toBe(b.info.id)
expect(a.info.role).toBe("assistant")
}),
)
it.instance("prompt submitted during an active run is included in the next LLM input", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const gate = yield* Deferred.make<void>()
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.hold("first", deferredAsPromise(gate))
yield* llm.text("second")
const a = yield* prompt
.prompt({
sessionID: chat.id,
agent: "build",
model: ref,
parts: [{ type: "text", text: "first" }],
})
expect(a.info.id).toBe(b.info.id)
expect(a.info.role).toBe("assistant")
}),
3_000,
.pipe(Effect.forkChild)
yield* llm.wait(1)
yield* waitForBusy(chat.id)
const id = MessageID.ascending()
const b = yield* prompt
.prompt({
sessionID: chat.id,
messageID: id,
agent: "build",
model: ref,
parts: [{ type: "text", text: "second" }],
})
.pipe(Effect.forkChild)
yield* pollWithTimeout(
sessions
.messages({ sessionID: chat.id })
.pipe(
Effect.map((msgs) => (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id) ? true : undefined)),
),
"timed out waiting for second prompt to save",
)
yield* Deferred.succeed(gate, void 0)
const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
expect(Exit.isSuccess(ea)).toBe(true)
expect(Exit.isSuccess(eb)).toBe(true)
expect(yield* llm.calls).toBe(2)
const msgs = yield* sessions.messages({ sessionID: chat.id })
const assistants = msgs.filter((msg) => msg.info.role === "assistant")
expect(assistants).toHaveLength(2)
const last = assistants.at(-1)
if (!last || last.info.role !== "assistant") throw new Error("expected second assistant")
expect(last.info.parentID).toBe(id)
expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
const inputs = yield* llm.inputs
expect(inputs).toHaveLength(2)
const messages = inputs.at(-1)?.messages
if (!Array.isArray(messages)) throw new Error("expected LLM messages")
expect(messages.at(-1)).toEqual({ role: "user", content: "second" })
}),
)
it.instance(
"prompt submitted during an active run is included in the next LLM input",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const gate = yield* Deferred.make<void>()
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
it.instance("assertNotBusy fails with BusyError when loop running", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const run = yield* SessionRunState.Service
const sessions = yield* Session.Service
yield* llm.hang
yield* llm.hold("first", deferredAsPromise(gate))
yield* llm.text("second")
const chat = yield* sessions.create({})
yield* user(chat.id, "hi")
const a = yield* prompt
.prompt({
sessionID: chat.id,
agent: "build",
model: ref,
parts: [{ type: "text", text: "first" }],
})
.pipe(Effect.forkChild)
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
yield* waitForBusy(chat.id)
yield* llm.wait(1)
const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id })
}
const id = MessageID.ascending()
const b = yield* prompt
.prompt({
sessionID: chat.id,
messageID: id,
agent: "build",
model: ref,
parts: [{ type: "text", text: "second" }],
})
.pipe(Effect.forkChild)
yield* pollWithTimeout(
sessions
.messages({ sessionID: chat.id })
.pipe(
Effect.map((msgs) =>
msgs.some((msg) => msg.info.role === "user" && msg.info.id === id) ? true : undefined,
),
),
"timed out waiting for second prompt to save",
)
yield* Deferred.succeed(gate, void 0)
const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
expect(Exit.isSuccess(ea)).toBe(true)
expect(Exit.isSuccess(eb)).toBe(true)
expect(yield* llm.calls).toBe(2)
const msgs = yield* sessions.messages({ sessionID: chat.id })
const assistants = msgs.filter((msg) => msg.info.role === "assistant")
expect(assistants).toHaveLength(2)
const last = assistants.at(-1)
if (!last || last.info.role !== "assistant") throw new Error("expected second assistant")
expect(last.info.parentID).toBe(id)
expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
const inputs = yield* llm.inputs
expect(inputs).toHaveLength(2)
const messages = inputs.at(-1)?.messages
if (!Array.isArray(messages)) throw new Error("expected LLM messages")
expect(messages.at(-1)).toEqual({ role: "user", content: "second" })
}),
3_000,
)
it.instance(
"assertNotBusy fails with BusyError when loop running",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const run = yield* SessionRunState.Service
const sessions = yield* Session.Service
yield* llm.hang
const chat = yield* sessions.create({})
yield* user(chat.id, "hi")
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id })
}
yield* prompt.cancel(chat.id)
yield* Fiber.await(fiber)
}),
3_000,
yield* prompt.cancel(chat.id)
yield* Fiber.await(fiber)
}),
)
noLLMServer.instance("assertNotBusy succeeds when idle", () =>
@@ -1395,31 +1382,29 @@ noLLMServer.instance("assertNotBusy succeeds when idle", () =>
// Shell semantics
it.instance(
"shell rejects with BusyError when loop running",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.hang
yield* user(chat.id, "hi")
it.instance("shell rejects with BusyError when loop running", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.hang
yield* user(chat.id, "hi")
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
yield* waitForBusy(chat.id)
const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id })
}
const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id })
}
yield* prompt.cancel(chat.id)
yield* Fiber.await(fiber)
}),
3_000,
yield* prompt.cancel(chat.id)
yield* Fiber.await(fiber)
}),
)
unixNoLLMServer(
@@ -1630,7 +1615,7 @@ it.instance(
expect(yield* llm.calls).toBe(1)
}),
{ git: true },
3_000,
10_000,
)
it.instance(
@@ -1669,7 +1654,7 @@ it.instance(
expect(yield* llm.calls).toBe(1)
}),
{ git: true },
3_000,
10_000,
)
unix(
@@ -1811,7 +1796,6 @@ unix(
yield* llm.tool("bash", {
command:
'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; printf truncation-ready; sleep 30',
description: "Print many lines",
timeout: 30_000,
workdir: path.resolve(dir),
})
@@ -2133,45 +2117,43 @@ it.instance("does not loop empty assistant turns for a simple reply", () =>
}),
)
it.instance(
"records aborted errors when prompt is cancelled mid-stream",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Prompt cancel regression" })
it.instance("records aborted errors when prompt is cancelled mid-stream", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Prompt cancel regression" })
yield* llm.hang
yield* llm.hang
const fiber = yield* prompt
.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Cancel me" }],
})
.pipe(Effect.forkChild)
const fiber = yield* prompt
.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Cancel me" }],
})
.pipe(Effect.forkChild)
yield* llm.wait(1)
yield* prompt.cancel(session.id)
yield* llm.wait(1)
yield* waitForBusy(session.id)
yield* prompt.cancel(session.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
expect(exit.value.info.role).toBe("assistant")
if (exit.value.info.role === "assistant") {
expect(exit.value.info.error?.name).toBe("MessageAbortedError")
}
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
expect(exit.value.info.role).toBe("assistant")
if (exit.value.info.role === "assistant") {
expect(exit.value.info.error?.name).toBe("MessageAbortedError")
}
}
const msgs = yield* sessions.messages({ sessionID: session.id })
const last = msgs.findLast((msg) => msg.info.role === "assistant")
expect(last?.info.role).toBe("assistant")
if (last?.info.role === "assistant") {
expect(last.info.error?.name).toBe("MessageAbortedError")
}
}),
3_000,
const msgs = yield* sessions.messages({ sessionID: session.id })
const last = msgs.findLast((msg) => msg.info.role === "assistant")
expect(last?.info.role).toBe("assistant")
if (last?.info.role === "assistant") {
expect(last.info.error?.name).toBe("MessageAbortedError")
}
}),
)
// Agent variant
@@ -139,7 +139,6 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}`
yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", {
command,
description: "create test file",
})
yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done")
@@ -24,23 +24,6 @@ exports[`tool parameters JSON Schema (wire shape) bash 1`] = `
"description": "The command to execute",
"type": "string",
},
"description": {
"description":
"Clear, concise description of what this command does in 5-10 words. Examples:
Input: ls
Output: Lists files in current directory
Input: git status
Output: Shows working tree status
Input: npm install
Output: Installs package dependencies
Input: mkdir foo
Output: Creates directory 'foo'"
,
"type": "string",
},
"timeout": {
"description": "Optional timeout in milliseconds",
"exclusiveMinimum": 0,
@@ -55,7 +38,6 @@ Output: Creates directory 'foo'"
},
"required": [
"command",
"description",
],
"type": "object",
}
@@ -106,19 +106,16 @@ describe("tool parameters", () => {
})
describe("shell", () => {
test("accepts minimum: command + description", () => {
expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
test("accepts command", () => {
expect(parse(Shell, { command: "ls" })).toEqual({ command: "ls" })
})
test("accepts optional timeout + workdir", () => {
const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
const parsed = parse(Shell, { command: "ls", timeout: 5000, workdir: "/tmp" })
expect(parsed.timeout).toBe(5000)
expect(parsed.workdir).toBe("/tmp")
})
test("rejects missing description", () => {
expect(accepts(Shell, { command: "ls" })).toBe(false)
})
test("rejects missing command", () => {
expect(accepts(Shell, { description: "list" })).toBe(false)
expect(accepts(Shell, {})).toBe(false)
})
})
+4 -48
View File
@@ -182,7 +182,6 @@ describe("tool.shell", () => {
Effect.gen(function* () {
const result = yield* run({
command: "echo test",
description: "Echo test message",
})
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain("test")
@@ -204,7 +203,6 @@ describe("tool.shell", () => {
const result = yield* bash.execute(
{
command: "echo fallback",
description: "Echo fallback text",
},
ctx,
)
@@ -227,7 +225,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: "echo hello",
description: "Echo hello",
},
capture(requests),
)
@@ -249,7 +246,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: "echo foo && echo bar",
description: "Echo twice",
},
capture(requests),
)
@@ -273,7 +269,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: "Write-Host foo; if ($?) { Write-Host bar }",
description: "Check PowerShell conditional",
},
capture(requests),
)
@@ -303,7 +298,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: "Remove-Item -Recurse tmp",
description: "Remove a temp directory",
},
capture(requests, err),
),
@@ -331,7 +325,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: `cat ${file}`,
description: "Read wildcard path",
},
capture(requests, err),
),
@@ -359,7 +352,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: `echo $(cat "${file}")`,
description: "Read nested bash file",
},
capture(requests),
)
@@ -389,7 +381,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
description: "Copy Windows ini",
},
capture(requests, err),
),
@@ -415,7 +406,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: `Write-Output $(Get-Content ${file})`,
description: "Read nested PowerShell file",
},
capture(requests),
)
@@ -446,7 +436,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: 'Get-Content "C:../outside.txt"',
description: "Read drive-relative file",
},
capture(requests, err),
),
@@ -474,7 +463,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: 'Get-Content "$HOME/.ssh/config"',
description: "Read home config",
},
capture(requests, err),
),
@@ -503,7 +491,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: 'Get-Content "$PWD/../outside.txt"',
description: "Read pwd-relative file",
},
capture(requests, err),
),
@@ -531,7 +518,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: 'Get-Content "$PSHOME/outside.txt"',
description: "Read pshome file",
},
capture(requests, err),
),
@@ -567,7 +553,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
description: "Read Windows ini with missing env",
},
capture(requests, err),
),
@@ -598,7 +583,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: "Get-Content $env:WINDIR/win.ini",
description: "Read Windows ini from env",
},
capture(requests),
)
@@ -626,7 +610,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`,
description: "Read Windows ini from FileSystem provider",
},
capture(requests, err),
),
@@ -655,7 +638,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: "Get-Content ${env:WINDIR}/win.ini",
description: "Read Windows ini from braced env",
},
capture(requests, err),
),
@@ -682,7 +664,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: "Set-Location C:/Windows",
description: "Change location",
},
capture(requests),
)
@@ -710,7 +691,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: "Write-Output ('a' * 3)",
description: "Write repeated text",
},
capture(requests),
)
@@ -736,7 +716,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`,
description: "Read Windows ini with cmd",
},
capture(requests),
)
@@ -761,7 +740,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: "cd ../",
description: "Change to parent directory",
},
capture(requests, err),
),
@@ -786,7 +764,6 @@ describe("tool.shell permissions", () => {
{
command: "echo ok",
workdir: os.tmpdir(),
description: "Echo from temp dir",
},
capture(requests, err),
),
@@ -817,7 +794,6 @@ describe("tool.shell permissions", () => {
{
command: "echo ok",
workdir: dir,
description: "Echo from external dir",
},
capture(requests, err),
),
@@ -850,7 +826,6 @@ describe("tool.shell permissions", () => {
{
command: "echo ok",
workdir: "/tmp",
description: "Echo from Git Bash tmp",
},
capture(requests, err),
),
@@ -878,7 +853,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: "cat /tmp/opencode-does-not-exist",
description: "Read Git Bash tmp file",
},
capture(requests, err),
),
@@ -910,7 +884,6 @@ describe("tool.shell permissions", () => {
yield* fail(
{
command: `cat ${filepath}`,
description: "Read external file",
},
capture(requests, err),
),
@@ -922,7 +895,6 @@ describe("tool.shell permissions", () => {
expect(extDirReq!.always).toContain(expected)
expect(extDirReq!.metadata).toMatchObject({
command: `cat ${filepath}`,
description: "Read external file",
directories: [outerTmp],
patterns: [expected],
})
@@ -942,7 +914,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: `rm -rf ${path.join(tmp, "nested")}`,
description: "Remove nested dir",
},
capture(requests),
)
@@ -963,7 +934,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: "git log --oneline -5",
description: "Git log",
},
capture(requests),
)
@@ -985,7 +955,6 @@ describe("tool.shell permissions", () => {
yield* run(
{
command: "cd .",
description: "Stay in current directory",
},
capture(requests),
)
@@ -1004,12 +973,9 @@ describe("tool.shell permissions", () => {
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{ command: "echo test > output.txt", description: "Redirect test output" },
capture(requests, err),
),
).toMatchObject({ message: err.message })
expect(yield* fail({ command: "echo test > output.txt" }, capture(requests, err))).toMatchObject({
message: err.message,
})
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("echo test > output.txt")
@@ -1025,7 +991,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run({ command: "ls -la", description: "List" }, capture(requests))
yield* run({ command: "ls -la" }, capture(requests))
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.always[0]).toBe("ls *")
@@ -1047,7 +1013,6 @@ describe("tool.shell abort", () => {
const res = yield* run(
{
command: `echo before && sleep 30`,
description: "Long running command",
},
{
...ctx,
@@ -1078,7 +1043,6 @@ describe("tool.shell abort", () => {
Effect.gen(function* () {
const result = yield* run({
command: `sleep 60`,
description: "Timeout test",
timeout: 500,
})
expect(result.output).toContain("shell tool terminated command after exceeding timeout")
@@ -1099,7 +1063,6 @@ describe("tool.shell abort", () => {
const result = yield* tool.execute(
{
command: `sleep 60`,
description: "Default timeout test",
},
ctx,
)
@@ -1116,7 +1079,6 @@ describe("tool.shell abort", () => {
Effect.gen(function* () {
const result = yield* run({
command: `echo stdout_msg && echo stderr_msg >&2`,
description: "Stderr test",
})
expect(result.output).toContain("stdout_msg")
expect(result.output).toContain("stderr_msg")
@@ -1132,7 +1094,6 @@ describe("tool.shell abort", () => {
Effect.gen(function* () {
const result = yield* run({
command: `exit 42`,
description: "Non-zero exit",
})
expect(result.metadata.exit).toBe(42)
}),
@@ -1147,7 +1108,6 @@ describe("tool.shell abort", () => {
const result = yield* run(
{
command: `echo first && sleep 0.1 && echo second`,
description: "Streaming test",
},
{
...ctx,
@@ -1174,7 +1134,6 @@ describe("tool.shell truncation", () => {
const lineCount = Truncate.MAX_LINES + 500
const result = yield* run({
command: fill("lines", lineCount),
description: "Generate lines exceeding limit",
})
mustTruncate(result)
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
@@ -1190,7 +1149,6 @@ describe("tool.shell truncation", () => {
const byteCount = Truncate.MAX_BYTES + 10000
const result = yield* run({
command: fill("bytes", byteCount),
description: "Generate bytes exceeding limit",
})
mustTruncate(result)
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
@@ -1205,7 +1163,6 @@ describe("tool.shell truncation", () => {
Effect.gen(function* () {
const result = yield* run({
command: fill("lines", 1),
description: "Generate one line",
})
expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
expect(result.output).toContain("1")
@@ -1220,7 +1177,6 @@ describe("tool.shell truncation", () => {
const lineCount = Truncate.MAX_LINES + 100
const result = yield* run({
command: fill("lines", lineCount),
description: "Generate lines for file check",
})
mustTruncate(result)