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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}`),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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"
|
||||
}),
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user