Compare commits

...

15 Commits

Author SHA1 Message Date
Aiden Cline 4abfeb4964 test(core): cover patch edge cases 2026-07-22 14:19:34 -05:00
Aiden Cline 4de6a5f60a fix(core): improve patch errors 2026-07-22 13:54:33 -05:00
Aiden Cline 2271f9b222 feat(ai): support PDF inputs (#38253) 2026-07-22 13:42:29 -05:00
James Long 8a36abd328 feat(tui): add developer debug bar (#38359) 2026-07-22 14:35:28 -04:00
Aiden Cline 532292b5f3 fix(core): reject malformed patch hunks (#38188) 2026-07-22 13:11:51 -05:00
Dax Raad 89e3141079 fix(core): preserve cache for session generation 2026-07-22 12:21:40 -04:00
Kit Langton 7a1f9764a2 feat(core): compact durable tool metadata (#38343) 2026-07-22 12:09:48 -04:00
opencode-agent[bot] b91dd78ab3 test(core): remove source text assertions (#38342)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-22 11:50:40 -04:00
James Long dba5da7c10 fix(tui): restore shell mode styling (#38231) 2026-07-22 11:23:50 -04:00
opencode-agent[bot] aea36d7630 fix(tui): prevent duplicate message forks (#38240)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 10:11:58 -05:00
opencode-agent[bot] 03474816ea test(core): stabilize wellknown event subscriptions (#38331)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 09:40:31 -05:00
Kit Langton 5a9ed4d350 fix: make tool progress live-only (#38217) 2026-07-22 10:40:07 -04:00
Simon Klee 4204b9d087 fix(tui): show providers in Mini model search (#38321) 2026-07-22 16:02:17 +02:00
Simon Klee 8fad13365b feat(tui): add mini splash setting (#38317)
Fix #38010
2026-07-22 15:46:50 +02:00
Simon Klee 5841b04fe7 tui: resolve default mini footer agent (#38315) 2026-07-22 15:42:12 +02:00
90 changed files with 3009 additions and 615 deletions
+36 -29
View File
@@ -27,6 +27,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "anthropic-messages"
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
@@ -56,6 +57,17 @@ const AnthropicImageBlock = Schema.Struct({
})
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
const AnthropicDocumentBlock = Schema.Struct({
type: Schema.tag("document"),
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
}),
cache_control: Schema.optional(AnthropicCacheControl),
})
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
const AnthropicThinkingBlock = Schema.Struct({
type: Schema.tag("thinking"),
thinking: Schema.String,
@@ -101,13 +113,10 @@ const AnthropicServerToolResultBlock = Schema.Struct({
})
type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
// Anthropic accepts either a plain string or an ordered array of text/image
// blocks inside `tool_result.content`. The array form is required when a tool
// returns image bytes (screenshot, image search, etc.) so they can be passed
// to the model as proper image inputs instead of being JSON-stringified into
// the prompt — which silently inflates context by megabytes and can push the
// conversation over the model's token limit.
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock])
// Anthropic accepts either a plain string or an ordered array of text, image, and
// document blocks inside `tool_result.content`. The array form keeps media as native
// model input instead of JSON-stringifying base64 into prompt text.
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicDocumentBlock])
const AnthropicToolResultBlock = Schema.Struct({
type: Schema.tag("tool_result"),
@@ -117,7 +126,12 @@ const AnthropicToolResultBlock = Schema.Struct({
cache_control: Schema.optional(AnthropicCacheControl),
})
const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock])
const AnthropicUserBlock = Schema.Union([
AnthropicTextBlock,
AnthropicImageBlock,
AnthropicDocumentBlock,
AnthropicToolResultBlock,
])
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
const AnthropicAssistantBlock = Schema.Union([
AnthropicTextBlock,
@@ -319,12 +333,17 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
})
const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) {
const media = yield* ProviderShared.validateMedia(
"Anthropic Messages",
part,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES)
if (media.mime === "application/pdf")
return {
type: "document" as const,
source: {
type: "base64" as const,
media_type: "application/pdf" as const,
data: media.base64,
},
} satisfies AnthropicDocumentBlock
return {
type: "image" as const,
source: {
@@ -335,25 +354,13 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me
} satisfies AnthropicImageBlock
})
// Tool results may carry structured text/images. Keep media as provider-native
// Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
item: ToolContent,
) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
const media = yield* ProviderShared.validateToolFile(
"Anthropic Messages",
item,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
return {
type: "image" as const,
source: {
type: "base64" as const,
media_type: media.mime,
data: media.base64,
},
} satisfies AnthropicImageBlock
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
})
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
@@ -445,7 +452,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "media") {
content.push(yield* lowerImage(part))
content.push(yield* lowerMedia(part))
continue
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
@@ -52,6 +52,7 @@ const BedrockToolResultContentItem = Schema.Union([
Schema.Struct({ text: Schema.String }),
Schema.Struct({ json: Schema.Unknown }),
BedrockMedia.ImageBlock,
BedrockMedia.DocumentBlock,
])
const BedrockToolResultBlock = Schema.Struct({
@@ -283,8 +284,6 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
data: item.uri,
filename: item.name,
})
if (!("image" in media))
return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
content.push(media)
}
return content
+24 -9
View File
@@ -41,9 +41,11 @@ const GeminiInlineDataPart = Schema.Struct({
data: Schema.String,
}),
})
type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
const GeminiFunctionCallPart = Schema.Struct({
functionCall: Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.String,
args: Schema.Unknown,
}),
@@ -52,8 +54,10 @@ const GeminiFunctionCallPart = Schema.Struct({
const GeminiFunctionResponsePart = Schema.Struct({
functionResponse: Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.String,
response: Schema.Unknown,
parts: Schema.optional(Schema.Array(GeminiInlineDataPart)),
}),
})
@@ -197,8 +201,13 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
: undefined
}
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
const google = providerMetadata?.google
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined
}
const lowerToolCall = (part: ToolCallPart) => ({
functionCall: { name: part.name, args: part.input },
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
thoughtSignature: thoughtSignature(part.providerMetadata),
})
@@ -255,6 +264,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (part.result.type !== "content") {
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
name: part.name,
response: {
name: part.name,
@@ -266,20 +276,23 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
}
const content: ReadonlyArray<ToolContent> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
const media: GeminiInlineDataPart[] = []
for (const item of content) {
if (item.type === "text") continue
const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
}
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
name: part.name,
response: {
name: part.name,
content: text.join("\n"),
},
parts: media.length > 0 ? media : undefined,
},
})
for (const item of content) {
if (item.type === "text") continue
const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
}
}
contents.push({ role: "user", parts })
}
@@ -441,6 +454,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("functionCall" in part) {
const input = part.functionCall.args
const id = `tool_${nextToolCallId++}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
@@ -453,9 +470,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
id,
name: part.functionCall.name,
input,
providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
: undefined,
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
}),
)
hasToolCalls = true
+70 -33
View File
@@ -11,6 +11,7 @@ import {
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ReasoningPart,
type TextPart,
@@ -28,6 +29,7 @@ import { ToolStream } from "./utils/tool-stream"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-responses"
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/responses"
@@ -42,7 +44,17 @@ const OpenAIResponsesInputImage = Schema.Struct({
type: Schema.tag("input_image"),
image_url: Schema.String,
})
const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
const OpenAIResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"),
filename: Schema.String,
file_data: Schema.String,
mime_type: Schema.optional(Schema.String),
})
const OpenAIResponsesInputContent = Schema.Union([
OpenAIResponsesInputText,
OpenAIResponsesInputImage,
OpenAIResponsesInputFile,
])
type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInputContent>
const OpenAIResponsesOutputText = Schema.Struct({
@@ -68,9 +80,13 @@ const OpenAIResponsesItemReference = Schema.Struct({
})
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images in addition to text.
// array of content items so tools can return images and files in addition to text.
// https://platform.openai.com/docs/api-reference/responses/object
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([
OpenAIResponsesInputText,
OpenAIResponsesInputImage,
OpenAIResponsesInputFile,
])
const OpenAIResponsesFunctionCallOutput = Schema.Union([
Schema.String,
@@ -343,42 +359,58 @@ const hostedToolItemID = (part: ToolResultPart) => {
: undefined
}
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
part: LLMRequest["messages"][number]["content"][number],
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") {
const media = yield* ProviderShared.validateMedia(
"OpenAI Responses",
part,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
return { type: "input_image" as const, image_url: media.dataUrl }
const lowerMedia = Effect.fn("OpenAIResponses.lowerMedia")(function* (part: MediaPart, provider: string) {
const media = yield* ProviderShared.validateMedia("OpenAI Responses", part, MEDIA_MIMES)
if (media.mime === "application/pdf") {
// xAI models inline bytes and MIME separately; OpenAI uses a data URL in file_data.
if (provider === "xai")
return {
type: "input_file" as const,
filename: part.filename ?? "document.pdf",
file_data: media.base64,
mime_type: media.mime,
}
return {
type: "input_file" as const,
filename: part.filename ?? "document.pdf",
file_data: media.dataUrl,
}
}
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
})
// Tool results may carry structured text/images. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
item: ToolContent,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
const media = yield* ProviderShared.validateToolFile(
"OpenAI Responses",
item,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
return { type: "input_image" as const, image_url: media.dataUrl }
})
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) {
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
part: LLMRequest["messages"][number]["content"][number],
provider: string,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMedia(part, provider)
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
})
// Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
item: ToolContent,
provider: string,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
provider,
)
})
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (
part: ToolResultPart,
provider: string,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem)
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, provider))
})
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
@@ -401,7 +433,10 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
}
if (message.role === "user") {
input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) })
input.push({
role: "user",
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request.model.provider)),
})
continue
}
@@ -460,7 +495,9 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const content: ReadonlyArray<ToolContent> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, lowerToolResultContentItem),
content: yield* Effect.forEach(content, (item) =>
lowerToolResultContentItem(item, request.model.provider),
),
})
}
if (itemID) hostedToolReferences.add(itemID)
@@ -483,7 +520,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part),
output: yield* lowerToolResultOutput(part, request.model.provider),
})
}
}
+2 -1
View File
@@ -158,7 +158,8 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const
export const PDF_MIMES = ["application/pdf"] as const
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES] as const
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
@@ -49,10 +49,10 @@ const DOCUMENT_FORMATS = {
"text/markdown": "md",
} as const satisfies Record<string, DocumentFormat>
const documentBlock = (part: MediaPart, format: DocumentFormat, bytes: string): DocumentBlock => ({
const documentBlock = (name: string, format: DocumentFormat, bytes: string): DocumentBlock => ({
document: {
format,
name: part.filename ?? `document.${format}`,
name,
source: { bytes },
},
})
@@ -77,12 +77,14 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
if (documentFormat) {
if (!part.filename)
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
const media = yield* ProviderShared.validateMedia(
"Bedrock Converse",
part,
new Set<string>(Object.keys(DOCUMENT_FORMATS)),
)
return documentBlock(part, documentFormat, media.base64)
return documentBlock(part.filename, documentFormat, media.base64)
}
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
})
+22 -3
View File
@@ -68,10 +68,29 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
events:
settlement.result.type === "error"
? [
LLMEvent.toolError({ id: call.id, name: call.name, message: String(settlement.result.value), error }),
LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result }),
LLMEvent.toolError({
id: call.id,
name: call.name,
message: String(settlement.result.value),
error,
providerMetadata: call.providerMetadata,
}),
LLMEvent.toolResult({
id: call.id,
name: call.name,
result: settlement.result,
providerMetadata: call.providerMetadata,
}),
]
: [LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result, output: settlement.output })],
: [
LLMEvent.toolResult({
id: call.id,
name: call.name,
result: settlement.result,
output: settlement.output,
providerMetadata: call.providerMetadata,
}),
],
}
}
@@ -0,0 +1,35 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"tool",
"tool-result"
],
"name": "pdf/anthropic-tool-result",
"recordedAt": "2026-07-22T18:15:39.002Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzxyRpSVFgwTm6ccUr\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"user-input"
],
"name": "pdf/anthropic-user-input",
"recordedAt": "2026-07-22T18:15:37.979Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzsayb45rgfamcjFt3\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -0,0 +1,36 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"tool",
"tool-result"
],
"name": "pdf/bedrock-tool-result",
"recordedAt": "2026-07-22T18:15:52.400Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"call_pdf_1\",\"content\":[{\"text\":\"PDF read successfully\"},{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}}}]}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAqgAAAFLa0GiGCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSUyIsInJvbGUiOiJhc3Npc3RhbnQifXIDPnsAAADIAAAAV0lIuCQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUSJ9z2HHHgAAAMUAAABXsdh8lQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJJRC0ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PIn2+8d8RAAAAywAAAFcO6ML0CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVlcifSIeZ+kAAACzAAAAV8dKafoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiMzkxIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dyJ9HAStJQAAAMAAAABWDj/Dcws6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2NyJ9EPTSwQAAALAAAABRaYm2Hws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0SuCAcAAAA+AAAAE6MAqhiCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MzkyMn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIiwidXNhZ2UiOnsiaW5wdXRUb2tlbnMiOjIyNDEsIm91dHB1dFRva2VucyI6Niwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjIyNDd9fcd35Hw=",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,35 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"user-input"
],
"name": "pdf/bedrock-user-input",
"recordedAt": "2026-07-22T18:15:48.408Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAtgAAAFJ/wBIFCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNCIsInJvbGUiOiJhc3Npc3RhbnQifURlAvAAAADGAAAAV/Z4BkULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk8ifU1V/fQAAADWAAAAV5aYkccLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiSUQtIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMDEyMzQ1In1Rr1g8AAAAoAAAAFfgCoSoCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZiJ9UwQMPQAAAM8AAABX+2hkNAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIzOTEifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWSJ9ZmoyCwAAAJAAAABWNiwMuAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbCJ9wtmmXgAAAIgAAABR+NhFWAs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmciLCJzdG9wUmVhc29uIjoiZW5kX3R1cm4ifa8D/doAAADvAAAATl7C4/ALOmV2ZW50LXR5cGUHAAhtZXRhZGF0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7Im1ldHJpY3MiOnsibGF0ZW5jeU1zIjo0NTQ1fSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXYiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6MTYxNCwib3V0cHV0VG9rZW5zIjo2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MTYyMH19db4j2Q==",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,53 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"tool",
"tool-result"
],
"name": "pdf/gemini-tool-result",
"recordedAt": "2026-07-22T18:21:59.606Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"read_pdf\",\"args\": {\"path\": \"verification.pdf\"},\"id\": \"58shgmez\"},\"thoughtSignature\": \"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"args\":{\"path\":\"verification.pdf\"}},\"thoughtSignature\":\"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"response\":{\"name\":\"read_pdf\",\"content\":\"PDF read successfully\"},\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCHID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 123,\"candidatesTokenCount\": 8,\"totalTokenCount\": 184,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 123}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EqECCp4CARFNMg9obBl8O6iU9lawUIWiE+1vztZm9NtaT9FuyJz343hd9ruz+xPco4Q1DY1GF81ZiSI2ElBkt8Wfwsqtix9LNGSMvbZhhk/ZnB54t05M/Dft1kujcMvEdZUWUI/jWaJ349tO1bKVH9MacG5+gl0n4y8DwyQZSV3xIcet547drSkcA/TM03RB+yj1/dcLHsvUjmv9EnO897vZgO2Dk4tbZ2NyCtOeQ3JKVhUTLg2pjkGk+POCNiOdESWiUzxdQKw9LiV6nnzi071tXNiMeVimq6d7xAzRVNapI2uXynvn9Uk3eyn85purOFa8cKriK9oD6vcyGMqgd9+gu2m3to0IHqd7o+2YSr1m5qV1xT1R2/WRQEtb1b1AuOAU6w==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 1277,\"candidatesTokenCount\": 8,\"totalTokenCount\": 1338,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 1102},{\"modality\": \"TEXT\",\"tokenCount\": 175}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\n"
}
}
]
}
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"user-input"
],
"name": "pdf/gemini-user-input",
"recordedAt": "2026-07-22T18:20:55.140Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCH\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 2,\"totalTokenCount\": 127,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 8,\"totalTokenCount\": 133,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EokECoYEARFNMg8L4fpLqaX8tIQZcvw2vLt3WsFjGqpuJGgna0/AGczwuzndRcf3LGIEaliCf4ijVOb1AG4/VPBh1kMzfjeAyHhvWIe4yQVoBwI7BjpFyLie+SnGTXQXKKy5ygRqRLFsV6DcAixNXXBHJw2x/2Nhtriryqs4fhWrL/P7ppHC10sMnTwN6Mw5x20NKwgT+rrw6lvYmQe9rdQsBJ6Zmp0GpPlwZZiAgzvwPfVoNwHSGb54xe/T9wjISjwWNgpedhbsIBDRZFDwruS4x57KBKeMPO69GLfeMP8PJ7rpR0HgT7nRbrl/OdykG/jqSMTvoRSqxawsD+Yr/DukgGatyfB5Ic+X4RhD07URpkGTAu/cakBtzhSmM/hpzKU9m/cId1UCjopLTtonUqSAKkroPdp8kIYw0MI2OZCNVwbDrdClUPmjRKfcTkcC2jNj1rS+WDFbm+mo+SP3rDSvvCdyJuiXHGKiM2EhbYnu42aHVC6w7eAe4Gv3Fq/0faW47r0ihbiAohFB9XUA+fD07g83EjIuc9Q6BRVTTcBfoRkrR/yFZKt3qwPq02W6rPD13/1wAnMtabNcxePMMGk7Dlxwng9yPS0NEge2KD+miOj9SC4aTvOTq2451tfK1x3UZqqb205zGOPbjizhH/CA/PGkG84hdkAG4mrUK0rEHqeWwRXDsxpyfto=\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 530,\"candidatesTokenCount\": 8,\"totalTokenCount\": 653,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 520},{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -59,7 +59,12 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
...request.messages,
Message.assistant(state.assistantContent),
...dispatched.map(([call, dispatched]) =>
Message.tool({ id: call.id, name: call.name, result: dispatched.result }),
Message.tool({
id: call.id,
name: call.name,
result: dispatched.result,
providerMetadata: call.providerMetadata,
}),
),
],
})
@@ -235,9 +235,9 @@ describe("Anthropic Messages route", () => {
}),
)
// Regression: screenshot/read tool results must stay structured so base64
// image data is not JSON-stringified into `tool_result.content`.
it.effect("lowers image tool-result content as structured image blocks", () =>
// Regression: read tool results must stay structured so base64 media data is
// not JSON-stringified into `tool_result.content`.
it.effect("lowers media tool-result content as structured blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
@@ -253,6 +253,7 @@ describe("Anthropic Messages route", () => {
result: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
{ type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
],
}),
],
@@ -263,6 +264,7 @@ describe("Anthropic Messages route", () => {
expect(expectToolResult(prepared.body).content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
])
}),
)
@@ -292,7 +294,7 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("rejects non-image media in tool-result content with a clear error", () =>
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
@@ -756,7 +758,7 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("continues a conversation with user image content", () =>
it.effect("continues a conversation with user media content", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
@@ -766,6 +768,7 @@ describe("Anthropic Messages route", () => {
Message.user([
{ type: "text", text: "What is in this image?" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
]),
],
}),
@@ -781,6 +784,7 @@ describe("Anthropic Messages route", () => {
content: [
{ type: "text", text: "What is in this image?" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
],
@@ -549,10 +549,12 @@ describe("Bedrock Converse route", () => {
LLM.request({
id: "req_doc",
model,
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Summarize these documents." },
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
{ type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==" },
{ type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==", filename: "data.csv" },
]),
],
}),
@@ -563,10 +565,9 @@ describe("Bedrock Converse route", () => {
{
role: "user",
content: [
// Filename round-trips when supplied.
{ text: "Summarize these documents." },
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
// Falls back to a stable placeholder when filename is missing.
{ document: { format: "csv", name: "document.csv", source: { bytes: "Q1NWREFUQQ==" } } },
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
],
},
],
@@ -574,6 +575,96 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("requires names for document media", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
}),
).pipe(Effect.flip)
expect(error.message).toContain("document media requires a filename")
}),
)
it.effect("passes named document-only messages through for provider validation", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
cache: "none",
messages: [
Message.user({
type: "media",
mediaType: "application/pdf",
data: "UERGREFUQQ==",
filename: "report.pdf",
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
},
])
}),
)
it.effect("lowers document media in tool results", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Read successfully" },
{
type: "file",
uri: "data:application/pdf;base64,UERGREFUQQ==",
mime: "application/pdf",
name: "report",
},
],
},
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ toolUse: { toolUseId: "call_1", name: "read", input: { path: "report.pdf" } } }],
},
{
role: "user",
content: [
{
toolResult: {
toolUseId: "call_1",
status: "success",
content: [
{ text: "Read successfully" },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
],
},
},
],
},
])
}),
)
it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
+64 -11
View File
@@ -70,6 +70,7 @@ describe("Gemini route", () => {
Message.user([
{ type: "text", text: "What is in this image?" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=" },
]),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
@@ -81,7 +82,11 @@ describe("Gemini route", () => {
contents: [
{
role: "user",
parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
parts: [
{ text: "What is in this image?" },
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
{
role: "model",
@@ -90,7 +95,12 @@ describe("Gemini route", () => {
{
role: "user",
parts: [
{ functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } },
{
functionResponse: {
name: "lookup",
response: { name: "lookup", content: '{"forecast":"sunny"}' },
},
},
],
},
],
@@ -110,7 +120,7 @@ describe("Gemini route", () => {
}),
)
it.effect("continues image tool results as inline vision input without base64 text", () =>
it.effect("continues media tool results as inline model input without base64 text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({
@@ -125,6 +135,7 @@ describe("Gemini route", () => {
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
{ type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
],
},
}),
@@ -141,9 +152,12 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
},
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
],
},
])
@@ -174,8 +188,13 @@ describe("Gemini route", () => {
{
role: "user",
parts: [
{ functionResponse: { name: "read", response: { name: "read", content: "" } } },
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
{
functionResponse: {
name: "read",
response: { name: "read", content: "" },
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
},
},
],
},
])
@@ -372,7 +391,10 @@ describe("Gemini route", () => {
parts: [
{ text: "thinking", thought: true },
{ text: "", thought: true, thoughtSignature: "thought_sig" },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
{
functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
thoughtSignature: "tool_sig",
},
],
},
finishReason: "STOP",
@@ -398,7 +420,10 @@ describe("Gemini route", () => {
id: "reasoning-0",
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
expect(toolCall).toMatchObject({
id: "tool_0",
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
})
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
response.events.findIndex((event) => event.type === "tool-call"),
)
@@ -416,6 +441,13 @@ describe("Gemini route", () => {
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "tool_0",
name: "lookup",
result: "done",
resultType: "text",
providerMetadata: toolCall?.providerMetadata,
}),
],
}),
)
@@ -424,7 +456,22 @@ describe("Gemini route", () => {
role: "model",
parts: [
{ text: "thinking", thought: true, thoughtSignature: "thought_sig" },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
{
functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
thoughtSignature: "tool_sig",
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "provider_call",
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
])
@@ -498,7 +545,7 @@ describe("Gemini route", () => {
content: {
role: "model",
parts: [
{ functionCall: { name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "news" } } },
],
},
@@ -513,7 +560,13 @@ describe("Gemini route", () => {
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toEqual([
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "tool_0" } },
},
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
@@ -5,6 +5,7 @@ import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart,
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as XAI from "../../src/providers/xai"
import * as OpenAIResponses from "../../src/protocols/openai-responses"
import * as ProviderShared from "../../src/protocols/shared"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios"
@@ -16,6 +17,8 @@ const model = OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" })
const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5")
const request = LLM.request({
id: "req_1",
model,
@@ -524,7 +527,77 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("rejects non-image media in tool-result content with a clear error", () =>
it.effect("lowers PDF tool-result content as structured input_file array", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_tool_result_pdf",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{
type: "file",
uri: "data:application/pdf;base64,JVBERi0xLjQ=",
mime: "application/pdf",
name: "report.pdf",
},
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
])
}),
)
it.effect("uses xAI inline file encoding for PDF tool results", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: xaiModel,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{
type: "file",
uri: "data:application/pdf;base64,JVBERi0xLjQ=",
mime: "application/pdf",
name: "report.pdf",
},
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
mime_type: "application/pdf",
},
])
}),
)
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
@@ -1526,20 +1599,64 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers user image content", () =>
it.effect("lowers user image and PDF content", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_media",
model,
messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }],
content: [
{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
],
},
])
}),
)
it.effect("uses xAI inline file encoding for user PDFs", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: xaiModel,
messages: [
Message.user({
type: "media",
mediaType: "application/pdf",
data: "data:application/pdf;base64,JVBERi0xLjQ=",
filename: "report.pdf",
}),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
mime_type: "application/pdf",
},
],
},
])
}),
@@ -1551,11 +1668,11 @@ describe("OpenAI Responses route", () => {
LLM.request({
id: "req_media",
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })],
messages: [Message.user({ type: "media", mediaType: "application/x-tar", data: "AAECAw==" })],
}),
).pipe(Effect.flip)
expect(error.message).toContain("OpenAI Responses does not support media type application/pdf")
expect(error.message).toContain("OpenAI Responses does not support media type application/x-tar")
}),
)
@@ -0,0 +1,207 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMResponse, Message, ToolDefinition, type Model } from "../../src"
import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers"
import { LLMClient } from "../../src/route"
import { Tool } from "../../src/tool"
import { runTools } from "../lib/tool-runtime"
import { recordedTests } from "../recorded-test"
const CODE = "ORCHID-7391"
const PDF =
"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK"
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" })
const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" })
const google = Google.configure({ apiKey: process.env.GOOGLE_API_KEY ?? "fixture" })
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
const bedrock = AmazonBedrock.configure({
apiKey: process.env.AWS_BEDROCK_API_KEY ?? "fixture",
region: process.env.AWS_REGION ?? "us-east-1",
})
const targets: ReadonlyArray<{
readonly id: string
readonly name: string
readonly provider: string
readonly protocol: string
readonly requires: string
readonly filename: string
readonly maxTokens: number
readonly model: Model
}> = [
{
id: "openai",
name: "OpenAI Responses gpt-4o-mini",
provider: "openai",
protocol: "openai-responses",
requires: "OPENAI_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
model: openai.responses("gpt-4o-mini"),
},
{
id: "anthropic",
name: "Anthropic Haiku 4.5",
provider: "anthropic",
protocol: "anthropic-messages",
requires: "ANTHROPIC_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
model: anthropic.model("claude-haiku-4-5-20251001"),
},
{
id: "gemini",
name: "Gemini 3.5 Flash",
provider: "google",
protocol: "gemini",
requires: "GOOGLE_API_KEY",
filename: "verification.pdf",
maxTokens: 256,
model: google.model("gemini-3.5-flash"),
},
{
id: "xai",
name: "xAI Grok 4.5",
provider: "xai",
protocol: "openai-responses",
requires: "XAI_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
model: xai.responses("grok-4.5"),
},
{
id: "bedrock",
name: "Bedrock Claude Haiku 4.5",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: "AWS_BEDROCK_API_KEY",
filename: "verification",
maxTokens: 40,
model: bedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0"),
},
]
const recorded = recordedTests({ prefix: "pdf", tags: ["pdf"] })
const prompt = "Return only the verification code from the PDF."
const readPdf = ToolDefinition.make({
name: "read_pdf",
description: "Read the attached PDF.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
})
const readPdfRuntime = Tool.make({
description: readPdf.description,
parameters: Schema.Struct({ path: Schema.String }),
success: Schema.String,
execute: () => Effect.succeed("PDF read successfully"),
toModelOutput: () => [
{ type: "text", text: "PDF read successfully" },
{
type: "file",
uri: `data:application/pdf;base64,${PDF}`,
mime: "application/pdf",
name: "verification.pdf",
},
],
})
const expectCode = (response: LLMResponse) => {
expect(response.finishReason).toBe("stop")
expect(response.text.toUpperCase()).toContain(CODE)
}
describe("PDF recorded", () => {
for (const target of targets) {
recorded.effect.with(
`reads a user PDF with ${target.name}`,
{
id: `${target.id}-user-input`,
provider: target.provider,
protocol: target.protocol,
requires: [target.requires],
tags: ["user-input"],
},
Effect.gen(function* () {
expectCode(
yield* LLMClient.generate(
LLM.request({
id: `recorded_pdf_${target.id}_user_input`,
model: target.model,
cache: "none",
generation: { maxTokens: target.maxTokens, temperature: 0 },
messages: [
Message.user([
{ type: "media", mediaType: "application/pdf", data: PDF, filename: target.filename },
{ type: "text", text: prompt },
]),
],
}),
),
)
}),
)
recorded.effect.with(
`reads a PDF tool result with ${target.name}`,
{
id: `${target.id}-tool-result`,
provider: target.provider,
protocol: target.protocol,
requires: [target.requires],
tags: ["tool", "tool-result"],
},
Effect.gen(function* () {
if (target.id === "gemini") {
const events = Array.from(
yield* runTools({
request: LLM.request({
id: "recorded_pdf_gemini_tool_result",
model: target.model,
system:
"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.",
prompt: "Use read_pdf with path verification.pdf and return the verification code.",
cache: "none",
generation: { maxTokens: target.maxTokens, temperature: 0 },
}),
tools: { read_pdf: readPdfRuntime },
}).pipe(Stream.runCollect),
)
expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
return
}
expectCode(
yield* LLMClient.generate(
LLM.request({
id: `recorded_pdf_${target.id}_tool_result`,
model: target.model,
system: "Read the PDF returned by the tool and follow the user's response format exactly.",
cache: "none",
generation: { maxTokens: target.maxTokens, temperature: 0 },
messages: [
Message.user(prompt),
Message.assistant([{ type: "tool-call", id: "call_pdf_1", name: readPdf.name, input: {} }]),
Message.tool({
id: "call_pdf_1",
name: readPdf.name,
resultType: "content",
result: [
{ type: "text", text: "PDF read successfully" },
{
type: "file",
uri: `data:application/pdf;base64,${PDF}`,
mime: "application/pdf",
name: target.filename,
},
],
}),
],
tools: [readPdf],
}),
),
)
}),
)
}
})
+45
View File
@@ -183,6 +183,51 @@ describe("LLMClient tools", () => {
}),
)
it.effect("preserves provider metadata on dispatched tool results", () =>
Effect.gen(function* () {
const tool = Tool.make({
description: "Return text.",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.succeed("hello"),
})
const providerMetadata = { google: { functionCallId: "provider_call" } }
const dispatched = yield* ToolRuntime.dispatch(
{ tool },
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
)
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "tool",
result: { type: "text", value: "hello" },
output: { structured: "hello", content: [{ type: "text", text: "hello" }] },
providerMetadata,
}),
])
const failed = yield* ToolRuntime.dispatch(
{},
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
)
expect(failed.events).toEqual([
LLMEvent.toolError({
id: "call_2",
name: "missing",
message: "Unknown tool: missing",
providerMetadata,
}),
LLMEvent.toolResult({
id: "call_2",
name: "missing",
result: { type: "error", value: "Unknown tool: missing" },
providerMetadata,
}),
])
}),
)
it.effect("uses the narrow default projection for encoded typed success", () =>
Effect.gen(function* () {
const text = Tool.make({
+2 -2
View File
@@ -198,8 +198,8 @@ export async function streamTurn(input: {
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: current.structured,
content: current.content,
structured: event.data.metadata ?? current.structured,
content: event.data.content ?? current.content,
error: event.data.error.message,
cwd: input.cwd,
}),
+7 -5
View File
@@ -398,6 +398,8 @@ export async function runNonInteractivePrompt(input: Input) {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message
const structured = event.data.metadata ?? current.structured
const content = event.data.content ?? current.content
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
@@ -408,8 +410,8 @@ export async function runNonInteractivePrompt(input: Input) {
state: {
status: "error",
input: current.input,
structured: current.structured,
content: current.content,
structured,
content,
error: event.data.error,
result: event.data.result,
},
@@ -439,14 +441,14 @@ export async function runNonInteractivePrompt(input: Input) {
renderedTools.add(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
if (toolOutputText(current.tool, current.content).trim())
if (toolOutputText(current.tool, content).trim())
await input.renderTool({
...tool,
state: {
status: "completed",
input: current.input,
structured: current.structured,
content: current.content,
structured,
content,
result: event.data.result,
},
})
+4 -2
View File
@@ -214,7 +214,7 @@ describe("acp event behavior", () => {
}),
)
send(
durableEvent("session.tool.progress", {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
@@ -251,7 +251,7 @@ describe("acp event behavior", () => {
}),
)
send(
durableEvent("session.tool.progress", {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
@@ -265,6 +265,8 @@ describe("acp event behavior", () => {
assistantMessageID: "msg_tools",
callID: "call_fail",
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
executed: true,
}),
)
+2 -2
View File
@@ -131,7 +131,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
})
}),
)
@@ -139,7 +139,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
})
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
} finally {
+77 -3
View File
@@ -130,7 +130,6 @@ function failedTool(inputID: string): V2Event[] {
id: "evt_failed_tool_progress",
created: 3,
type: "session.tool.progress",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
@@ -149,6 +148,8 @@ function failedTool(inputID: string): V2Event[] {
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
error: { type: "unknown", message: "tool failed" },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
executed: true,
},
},
@@ -156,6 +157,53 @@ function failedTool(inputID: string): V2Event[] {
]
}
function successfulGrep(inputID: string): V2Event[] {
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
return [
prompted(inputID),
{
id: "evt_grep_input",
created: 1,
type: "session.tool.input.started",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
name: "grep",
},
},
{
id: "evt_grep_called",
created: 2,
type: "session.tool.called",
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
input: { pattern: "needle" },
executed: true,
},
},
{
id: "evt_grep_success",
created: 3,
type: "session.tool.success",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
structured: { matches: 2 },
content: [{ type: "text", text }],
executed: false,
},
},
settled(),
]
}
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: {
@@ -268,6 +316,32 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("keeps formatted tool output and compact structured metadata in JSON", async () => {
const output = await capture({ format: "json", turn: successfulGrep })
const events = output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
type: "tool_use",
part: {
tool: "grep",
state: {
status: "completed",
output: expect.stringContaining("Found 2 matches"),
metadata: {
structured: { matches: 2 },
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
},
},
},
})
expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 })
expect(events[0].part.state.metadata.result).toBeUndefined()
})
test("uses session.wait then reconciles projected output without a terminal event", async () => {
const idle = Promise.withResolvers<void>()
let done = false
@@ -440,11 +514,11 @@ describe("runNonInteractivePrompt", () => {
expect(output).toEqual({ stdout: "", stderr: "", exitCode: 0 })
})
test("renders native failed tool output before the terminal error", async () => {
test("renders a native terminal failure snapshot when live progress was missed", async () => {
const rendered: SessionMessageAssistantTool[] = []
const failed: SessionMessageAssistantTool[] = []
await capture({
turn: failedTool,
turn: (inputID) => failedTool(inputID).filter((event) => event.type !== "session.tool.progress"),
renderTool: (part) => {
rendered.push(part)
return Promise.resolve()
+35 -35
View File
@@ -1387,24 +1387,6 @@ export type SessionToolCalled = {
}
}
export type SessionToolFailed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.failed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
result?: any
executed: boolean
resultState?: SessionMessageProviderState7
}
}
export type ModelCompatibility = { reasoningField?: ModelReasoningField }
export type ModelCost = {
@@ -1851,22 +1833,6 @@ export type SessionMessageToolStateError = {
result?: JsonValue
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
}
}
export type SessionToolSuccess = {
id: string
created: number
@@ -1886,6 +1852,41 @@ export type SessionToolSuccess = {
}
}
export type SessionToolFailed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.failed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: any }
result?: any
executed: boolean
resultState?: SessionMessageProviderState7
}
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
}
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
@@ -2224,7 +2225,6 @@ export type SessionEventDurable =
| SessionToolInputStarted
| SessionToolInputEnded
| SessionToolCalled
| SessionToolProgress
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
+1
View File
@@ -55,5 +55,6 @@ export const migrations = (
import("./migration/20260709190621_session_pending_table"),
import("./migration/20260710025429_instruction_sync"),
import("./migration/20260716020354_kv"),
import("./migration/20260722011141_delete_tool_progress_events"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260722011141_delete_tool_progress_events",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.tool.progress.1';`)
})
},
} satisfies DatabaseMigration.Migration
+28 -5
View File
@@ -12,6 +12,7 @@ import { SessionGenerate } from "./generate"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import { ToolRegistry } from "../tool/registry"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
@@ -23,6 +24,7 @@ export const layer = Layer.effect(
const hooks = yield* PluginHooks.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const registry = yield* ToolRegistry.Service
const app = yield* App.Metadata
return SessionGenerate.Service.of({
@@ -34,6 +36,9 @@ export const layer = Layer.effect(
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
? selection.session.id.slice(4)
: selection.session.id
const executableTools = yield* registry.materialize(selection.agent.info.permissions)
const toolDefinitions = executableTools.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: selection.session.id,
agent: selection.agent.id,
@@ -46,24 +51,34 @@ export const layer = Layer.effect(
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
tools: {},
tools: Object.fromEntries(
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
})
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = toolsByName.get(name)
return registered
? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })]
: []
})
yield* Effect.logInfo("sending session generation request", {
sessionID: selection.session.id,
providerID: model.ref.providerID,
modelID: model.ref.id,
})
return (yield* llm.generate(
const response = yield* llm.generate(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, app) },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
tools: [],
tools: hookedTools,
toolChoice: "none",
}),
)).text
)
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text
}),
})
}),
@@ -72,5 +87,13 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
deps: [
SessionContext.node,
Database.node,
PluginHooks.node,
SessionRunnerModel.node,
ToolRegistry.node,
App.node,
llmClient,
],
})
+2 -2
View File
@@ -402,8 +402,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: match.state.status === "running" ? match.state.structured : {},
content: match.state.status === "running" ? match.state.content : [],
structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}),
content: event.data.content ?? (match.state.status === "running" ? match.state.content : []),
result: event.data.result,
}),
)
-1
View File
@@ -697,7 +697,6 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Progress, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
+1 -10
View File
@@ -163,16 +163,7 @@ const layer = Layer.effect(
agent: agent.id,
messageID: assistantMessageID,
call: event,
progress: (update) =>
serialized(
events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id,
assistantMessageID,
callID: event.id,
structured: { ...update.structured },
content: [...update.content],
}),
),
progress: (update) => serialized(publisher.progress(event.id, update)),
}),
).pipe(
Effect.flatMap((settlement) =>
@@ -11,6 +11,7 @@ import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot"
import { RelativePath } from "../../schema"
import { SessionUsage } from "../usage"
import type { ToolRegistry } from "../../tool/registry"
type Input = {
readonly sessionID: SessionSchema.ID
@@ -54,8 +55,17 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
called: boolean
settled: boolean
providerExecuted: boolean
progress?: ToolRegistry.Progress
}
>()
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
if (!tool.progress) return {}
const first = tool.progress.content[0]
return {
...(first === undefined ? {} : { content: [first, ...tool.progress.content.slice(1)] as const }),
metadata: tool.progress.structured,
}
}
let assistantMessageID = input.assistantMessageID
let stepStarted = false
let stepFailed = false
@@ -232,6 +242,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
type: "tool.input-json",
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
},
...failureSnapshot(tool),
executed: false,
})
})
@@ -256,6 +267,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
assistantMessageID: tool.assistantMessageID,
callID,
error,
...failureSnapshot(tool),
executed: tool.providerExecuted,
})
}
@@ -415,6 +427,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: result.error,
...failureSnapshot(tool),
result: event.result,
executed,
resultState,
@@ -447,6 +460,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
event.message === `Unknown tool: ${event.name}`
? { type: "tool.unknown", message: event.message }
: { type: "tool.execution", message: event.message },
...failureSnapshot(tool),
executed: tool.providerExecuted,
resultState: providerState(event.providerMetadata),
})
@@ -471,8 +485,23 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
}
})
const progress = Effect.fnUntraced(function* (callID: string, update: ToolRegistry.Progress) {
const tool = tools.get(callID)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
const current = { structured: { ...update.structured }, content: [...update.content] }
tool.progress = current
yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
...current,
})
})
return {
publish,
progress,
flush,
failAssistant,
publishStepFailure,
+6 -1
View File
@@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { NonNegativeInt, RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@@ -25,6 +25,9 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Entry)
const StructuredOutput = Schema.Struct({
count: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded
/** Format raw search results into the concise line-oriented output models expect. */
@@ -51,6 +54,8 @@ export const Plugin = {
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ count: output.length }),
toModelOutput: ({ output }) => [
{
type: "text",
+6 -1
View File
@@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { NonNegativeInt, RelativePath } from "../schema"
import { Tool } from "./tool"
export const name = "grep"
@@ -30,6 +30,9 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Match)
const StructuredOutput = Schema.Struct({
matches: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded
/** Format raw search matches into the familiar concise model output. */
@@ -65,6 +68,8 @@ export const Plugin = {
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ matches: output.length }),
toModelOutput: ({ output }) => [
{
type: "text",
+46 -19
View File
@@ -5,6 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
@@ -84,11 +85,24 @@ export const Plugin = {
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string, error?: unknown) => {
const prefix =
const detail = error === undefined ? "" : `: ${errorMessage(error)}`
if (applied.length === 0) {
return new ToolFailure({ message: `Unable to apply patch at ${path}${detail}`, error })
}
return new ToolFailure({
message: `Patch partially applied before failing at ${path}${detail}. Completed before failure: ${applied.map((item) => item.resource).join(", ")}`,
error,
})
}
const failMoveRemoval = (source: string, destination: string, error: unknown) => {
const previous =
applied.length === 0
? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
return new ToolFailure({ message: prefix, error })
? ""
: `. Completed before move: ${applied.map((item) => item.resource).join(", ")}`
return new ToolFailure({
message: `Patch partially applied while moving ${source} to ${destination}: wrote ${destination} but failed to remove ${source}: ${errorMessage(error)}${previous}`,
error,
})
}
return Effect.gen(function* () {
const source = {
@@ -103,11 +117,7 @@ export const Plugin = {
),
)
if (hunks.length === 0) {
const normalized = input.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
return yield* new ToolFailure({ message: "patch verification failed: no hunks found" })
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
const prepared: Prepared[] = []
const targets: Target[] = []
@@ -147,7 +157,7 @@ export const Plugin = {
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
message: `patch verification failed: Failed to read file to delete ${target.canonical}: ${errorMessage(error)}`,
}),
),
)
@@ -163,7 +173,7 @@ export const Plugin = {
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
)
@@ -177,7 +187,7 @@ export const Plugin = {
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
),
@@ -186,7 +196,8 @@ export const Plugin = {
const before = original.replace(/^\uFEFF/, "")
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) => new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
catch: (error) =>
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
@@ -241,7 +252,7 @@ export const Plugin = {
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
).pipe(Effect.mapError((error) => fail(change.target.resource, error)))
applied.push({
type: change.type,
resource: change.target.resource,
@@ -250,7 +261,9 @@ export const Plugin = {
return
}
if (change.type === "delete") {
yield* fs.remove(change.target.canonical)
yield* fs
.remove(change.target.canonical)
.pipe(Effect.mapError((error) => fail(change.target.resource, error)))
applied.push({
type: change.type,
resource: change.target.resource,
@@ -259,8 +272,15 @@ export const Plugin = {
return
}
if (change.moveTarget) {
yield* fs.writeWithDirs(change.moveTarget.canonical, change.content)
yield* fs.remove(change.target.canonical)
const moveTarget = change.moveTarget
yield* fs
.writeWithDirs(moveTarget.canonical, change.content)
.pipe(Effect.mapError((error) => fail(moveTarget.resource, error)))
yield* fs.remove(change.target.canonical).pipe(
Effect.mapError((error) =>
failMoveRemoval(change.target.resource, moveTarget.resource, error),
),
)
applied.push({
type: change.type,
resource: change.moveTarget.resource,
@@ -268,13 +288,15 @@ export const Plugin = {
})
return
}
yield* fs.writeWithDirs(change.target.canonical, change.content)
yield* fs
.writeWithDirs(change.target.canonical, change.content)
.pipe(Effect.mapError((error) => fail(change.target.resource, error)))
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
}).pipe(Effect.mapError((error) => fail(change.path, error))),
}),
{ discard: true },
)
return { applied, files: patchFiles }
@@ -303,6 +325,11 @@ export const Plugin = {
}),
}
function errorMessage(error: unknown) {
if (error instanceof PlatformError) return error.reason.description ?? error.reason.message
return error instanceof Error ? error.message : String(error)
}
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(
+12 -3
View File
@@ -256,13 +256,22 @@ export const Plugin = {
}
}
let previousProgress: { readonly output: string; readonly truncated: boolean } | undefined
const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen(
captureShell().pipe(
Effect.flatMap((capture) =>
context.progress({
structured: { truncated: capture.truncated },
content: [{ type: "text", text: capture.output }],
Effect.gen(function* () {
if (
previousProgress?.output === capture.output &&
previousProgress.truncated === capture.truncated
)
return
previousProgress = capture
yield* context.progress({
structured: { truncated: capture.truncated },
content: [{ type: "text", text: capture.output }],
})
}),
),
),
+6
View File
@@ -21,6 +21,10 @@ export const Output = Schema.Struct({
directory: Schema.String,
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
name: Output.fields.name,
directory: Output.fields.directory,
})
export const description = [
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
@@ -66,6 +70,8 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ name: output.name, directory: output.directory }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
+6
View File
@@ -31,6 +31,10 @@ export const Output = Schema.Struct({
status: Schema.Literals(["completed", "running"]),
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
sessionID: Output.fields.sessionID,
status: Output.fields.status,
})
export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.",
@@ -115,6 +119,8 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
+5
View File
@@ -37,6 +37,9 @@ const Output = Schema.Struct({
format: Input.fields.format,
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
contentType: Output.fields.contentType,
})
type Format = (typeof Input.Type)["format"]
@@ -126,6 +129,8 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ contentType: output.contentType }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
+5
View File
@@ -190,6 +190,9 @@ const Output = Schema.Struct({
provider: Provider,
text: Schema.String,
})
const StructuredOutput = Schema.Struct({
provider: Output.fields.provider,
})
export const Plugin = {
id: "opencode.tool.websearch",
@@ -206,6 +209,8 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ provider: output.provider }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
@@ -24,6 +24,7 @@ import renameInstructionsMigration from "@opencode-ai/core/database/migration/20
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@@ -557,6 +558,31 @@ describe("DatabaseMigration", () => {
)
})
test("deletes durable tool progress without changing aggregate sequence watermarks", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 5)`)
yield* db.run(sql`INSERT INTO event VALUES ('evt_success', 'ses_test', 4, 'session.tool.success.1', '{}')`)
yield* db.run(sql`INSERT INTO event VALUES ('evt_progress', 'ses_test', 5, 'session.tool.progress.1', '{}')`)
yield* DatabaseMigration.applyOnly(db, [deleteToolProgressEventsMigration])
expect(yield* db.all(sql`SELECT id, seq, type, data FROM event ORDER BY seq`)).toEqual([
{ id: "evt_success", seq: 4, type: "session.tool.success.1", data: "{}" },
])
expect(yield* db.get(sql`SELECT aggregate_id, seq FROM event_sequence`)).toEqual({
aggregate_id: "ses_test",
seq: 5,
})
}),
)
})
test("records the authoritative parent sequence on existing forks", async () => {
await run(
Effect.gen(function* () {
+280 -9
View File
@@ -22,6 +22,30 @@ describe("Patch", () => {
])
})
test("parses an empty patch", () => {
expect(parse("*** Begin Patch\n*** End Patch")).toEqual([])
})
test("ignores a Codex environment preamble", () => {
expect(
parse("*** Begin Patch\n*** Environment ID: remote\n*** Add File: file.txt\n+content\n*** End Patch"),
).toEqual([{ type: "add", path: "file.txt", contents: "content" }])
})
test("parses an update followed by an add", () => {
expect(
parse("*** Begin Patch\n*** Update File: update.txt\n@@\n+line\n*** Add File: add.txt\n+content\n*** End Patch"),
).toEqual([
{
type: "update",
path: "update.txt",
movePath: undefined,
chunks: [{ oldLines: [], newLines: ["line"], changeContext: undefined }],
},
{ type: "add", path: "add.txt", contents: "content" },
])
})
test("parses a file move", () => {
expect(
parse("*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch"),
@@ -66,6 +90,20 @@ describe("Patch", () => {
])
})
test("strips quoted heredoc wrappers", () => {
const patch = "*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch"
expect(parse(`<<'EOF'\n${patch}\nEOF`)).toEqual([{ type: "add", path: "add.txt", contents: "added" }])
expect(parse(`<<\"EOF\"\n${patch}\nEOF`)).toEqual([{ type: "add", path: "add.txt", contents: "added" }])
})
test("rejects malformed heredoc wrappers", () => {
const patch = "*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch"
expect(() => parse(`<<\"EOF'\n${patch}\nEOF`)).toThrow("The first line of the patch must be '*** Begin Patch'")
expect(() => parse("<<EOF\n*** Begin Patch\n*** Add File: add.txt\n+added\nEOF")).toThrow(
"The last line of the patch must be '*** End Patch'",
)
})
test("parses a whitespace-padded hunk header", () => {
expect(parse("*** Begin Patch\n *** Update File: foo.txt\n@@\n-old\n+new\n*** End Patch")).toEqual([
{
@@ -99,6 +137,23 @@ describe("Patch", () => {
])
})
test("parses relative and absolute hunk paths", () => {
expect(
parse(
"*** Begin Patch\n*** Add File: relative.txt\n+content\n*** Delete File: /tmp/delete.txt\n*** Update File: /tmp/update.txt\n@@\n-old\n+new\n*** End Patch",
),
).toEqual([
{ type: "add", path: "relative.txt", contents: "content" },
{ type: "delete", path: "/tmp/delete.txt" },
{
type: "update",
path: "/tmp/update.txt",
movePath: undefined,
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined }],
},
])
})
test("strips one carriage return from CRLF patch lines", () => {
expect(parse("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n")).toEqual([
{
@@ -136,6 +191,42 @@ describe("Patch", () => {
])
})
test("allows an end-of-file marker before an explicit chunk", () => {
expect(
parse("*** Begin Patch\n*** Update File: file.txt\n*** End of File\n@@\n-old\n+new\n*** End Patch"),
).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined }],
},
])
})
test("allows an end-of-file marker before an implicit chunk and move", () => {
expect(parse("*** Begin Patch\n*** Update File: file.txt\n*** End of File\n-old\n+new\n*** End Patch")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["old"], newLines: ["new"] }],
},
])
expect(
parse(
"*** Begin Patch\n*** Update File: old.txt\n*** End of File\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch",
),
).toEqual([
{
type: "update",
path: "old.txt",
movePath: "new.txt",
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined }],
},
])
})
test("derives fuzzy line updates while preserving BOM", () => {
const update = Patch.derive("update.txt", [{ oldLines: [" old "], newLines: ["new"] }], "\uFEFFold\n")
expect(update).toEqual({ content: "new\n", bom: true })
@@ -155,6 +246,35 @@ describe("Patch", () => {
).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
})
test("appends a pure-addition chunk to a nonempty file", () => {
expect(Patch.derive("update.txt", [{ oldLines: [], newLines: ["added 1", "added 2"] }], "line 1\nline 2\n").content).toBe(
"line 1\nline 2\nadded 1\nadded 2\n",
)
})
test("applies a pure-addition chunk after an earlier replacement", () => {
expect(
Patch.derive(
"update.txt",
[
{ oldLines: [], newLines: ["after-context", "second-line"] },
{ oldLines: ["line1", "line2", "line3"], newLines: ["line1", "line2-replacement"] },
],
"line1\nline2\nline3\n",
).content,
).toBe("line1\nline2-replacement\nafter-context\nsecond-line\n")
})
test("applies a deletion-only update chunk", () => {
expect(
Patch.derive(
"update.txt",
[{ oldLines: ["line1", "line2", "line3"], newLines: ["line1", "line3"] }],
"line1\nline2\nline3\n",
).content,
).toBe("line1\nline3\n")
})
test("updates empty files and adds a trailing newline", () => {
expect(Patch.derive("empty.txt", [{ oldLines: [], newLines: ["First line"] }], "").content).toBe("First line\n")
expect(Patch.derive("no-newline.txt", [{ oldLines: ["old"], newLines: ["new"] }], "old").content).toBe("new\n")
@@ -236,15 +356,166 @@ describe("Patch", () => {
).toThrow("Failed to find expected lines")
})
test("matches V1 lenient parsing of malformed hunk bodies", () => {
expect(parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toEqual([
{ type: "add", path: "add.txt", contents: "" },
])
expect(parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toEqual([
{ type: "update", path: "update.txt", movePath: undefined, chunks: [] },
])
expect(parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toEqual([
{ type: "delete", path: "delete.txt" },
test("identifies a missing blank line", () => {
expect(() =>
Patch.derive("update.txt", [{ oldLines: [""], newLines: ["added"] }], "content\n"),
).toThrow("Failed to find an expected blank line in update.txt")
})
test("parses an update without an explicit first chunk header", () => {
expect(parse("*** Begin Patch\n*** Update File: file.txt\n import foo\n+bar\n*** End Patch")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["import foo"], newLines: ["import foo", "bar"] }],
},
])
})
test("keeps indented update markers as context lines", () => {
expect(
parse(
"*** Begin Patch\n*** Update File: a.txt\n@@\n-old a\n+new a\n *** Update File: b.txt\n@@\n-old b\n+new b\n*** End Patch",
),
).toEqual([
{
type: "update",
path: "a.txt",
movePath: undefined,
chunks: [
{
oldLines: ["old a", "*** Update File: b.txt"],
newLines: ["new a", "*** Update File: b.txt"],
changeContext: undefined,
},
{ oldLines: ["old b"], newLines: ["new b"], changeContext: undefined },
],
},
])
})
test("keeps indented move and EOF markers as context lines", () => {
expect(
parse(
"*** Begin Patch\n*** Update File: file.txt\n@@\n before\n *** Move to: moved.txt\n *** End of File\n*** End Patch",
),
).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [
{
oldLines: ["before", "*** Move to: moved.txt", "*** End of File"],
newLines: ["before", "*** Move to: moved.txt", "*** End of File"],
changeContext: undefined,
},
],
},
])
})
test("preserves update context indentation", () => {
expect(parse("*** Begin Patch\n*** Update File: file.txt\n@@ section\n-old\n+new\n*** End Patch")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: " section" }],
},
])
})
test("preserves bare empty update lines as context", () => {
expect(
parse("*** Begin Patch\n*** Update File: file.txt\n@@\n context before\n\n context after\n*** End Patch"),
).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [
{
oldLines: ["context before", "", "context after"],
newLines: ["context before", "", "context after"],
changeContext: undefined,
},
],
},
])
})
test("rejects invalid add and delete lines", () => {
expect(() => parse("*** Begin Patch\n*** Add File: file.txt\nbad\n*** End Patch")).toThrow(
"Invalid hunk at line 3: Invalid Add File line for 'file.txt': expected a line starting with '+', got 'bad'",
)
expect(() => parse("*** Begin Patch\n*** Delete File: file.txt\nbad\n*** End Patch")).toThrow(
"Invalid hunk at line 3: Unexpected line after Delete File 'file.txt': 'bad'. Delete hunks do not contain body lines",
)
expect(() =>
parse("*** Begin Patch\n*** Delete File: file.txt\n*** Frobnicate File: next.txt\n*** End Patch"),
).toThrow("Invalid hunk at line 3: '*** Frobnicate File: next.txt' is not a valid hunk header")
})
test("rejects an empty update hunk", () => {
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n*** End Patch")).toThrow(
"Invalid hunk at line 2: Update file hunk for path 'file.txt' is empty",
)
expect(() =>
parse(
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n*** Delete File: other.txt\n*** End Patch",
),
).toThrow("Invalid hunk at line 2: Update file hunk for path 'old.txt' is empty")
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n*** End of File\n*** End Patch")).toThrow(
"Invalid hunk at line 2: Update file hunk for path 'file.txt' is empty",
)
})
test("rejects an empty update chunk", () => {
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End Patch")).toThrow(
"Invalid hunk at line 4: Update hunk does not contain any lines",
)
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End of File\n*** End Patch")).toThrow(
"Invalid hunk at line 4: Update hunk does not contain any lines",
)
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n@@\n-old\n+new\n*** End Patch")).toThrow(
"Invalid hunk at line 4: Unexpected line found in update hunk: '@@'",
)
expect(() =>
parse("*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n@@\n-old\n+new\n*** End Patch"),
).toThrow("Invalid hunk at line 4: Unexpected line found in update hunk: '*** Update File: other.txt'")
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\nbad\n*** End Patch")).toThrow(
"Invalid hunk at line 4: Unexpected line found in update hunk: 'bad'",
)
})
test("rejects an invalid update line", () => {
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\nbad\n*** End Patch")).toThrow(
"Invalid hunk at line 5: Expected update hunk to start with a @@ context marker, got: 'bad'",
)
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@foo\n*** End Patch")).toThrow(
"Invalid hunk at line 3: Unexpected line found in update hunk: '@@foo'",
)
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n*** Frobnicate File: foo\n*** End Patch")).toThrow(
"Invalid hunk at line 5: Expected update hunk to start with a @@ context marker, got: '*** Frobnicate File: foo'",
)
})
test("rejects invalid and pathless hunk headers", () => {
expect(() => parse("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch")).toThrow(
"Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header",
)
expect(() => parse("*** Begin Patch\n*** Add File:\n*** End Patch")).toThrow(
"Invalid hunk at line 2: '*** Add File:' is not a valid hunk header",
)
for (const header of ["*** Add File: ", "*** Delete File: ", "*** Update File: "]) {
expect(() => parse(`*** Begin Patch\n${header}\n*** End Patch`)).toThrow(
`Invalid hunk at line 2: '${header.trim()}' is not a valid hunk header`,
)
}
expect(() =>
parse("*** Begin Patch\n*** Update File: old.txt\n*** Move to: \n@@\n-old\n+new\n*** End Patch"),
).toThrow("Invalid hunk at line 3: Move destination for 'old.txt' must not be empty")
})
})
+14 -2
View File
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
@@ -38,6 +38,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { asc, eq } from "drizzle-orm"
import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
@@ -92,6 +93,15 @@ const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succee
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(ToolRegistry.Service, {
materialize: () =>
Effect.succeed({
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
settle: () => Effect.die(new Error("unused")),
}),
register: () => Effect.die(new Error("unused")),
registerBatch: () => Effect.die(new Error("unused")),
})
const it = testEffect(
AppNodeBuilder.build(
@@ -114,6 +124,7 @@ const it = testEffect(
[ReferenceInstructions.node, references],
[McpInstructions.node, mcp],
[PluginSupervisor.node, plugins],
[ToolRegistry.node, tools],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
],
),
@@ -259,6 +270,7 @@ it.effect("generates from fresh settled Session context without durable mutation
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system"), ...event.system]
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
}),
)
@@ -287,7 +299,7 @@ it.effect("generates from fresh settled Session context without durable mutation
: [],
),
).toEqual(["Settled partial answer"])
expect(requests[0]?.tools).toEqual([])
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
expect(requests[0]?.toolChoice).toMatchObject({ type: "none" })
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Cause, Effect, Exit, Schema } from "effect"
import { LLMEvent } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import { EventV2 } from "@opencode-ai/core/event"
@@ -16,11 +16,11 @@ import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publis
const sessionID = SessionV2.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const capture = (providerMetadataKey = "anthropic") => {
const capture = (providerMetadataKey = "anthropic", options?: { readonly interruptProgress?: boolean }) => {
const published: Array<{ readonly type: string; readonly data: unknown }> = []
const events: Pick<EventV2.Interface, "publish"> = {
publish: (definition, data) =>
Effect.sync(() => {
publish: (definition, data) => {
const publish = Effect.sync(() => {
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
published.push({
type: definition.durable
@@ -29,7 +29,11 @@ const capture = (providerMetadataKey = "anthropic") => {
data,
})
return event
}),
})
return definition.type === SessionEvent.Tool.Progress.type && options?.interruptProgress
? publish.pipe(Effect.andThen(Effect.interrupt))
: publish
},
}
return {
published,
@@ -92,6 +96,34 @@ test("provider-executed success retains its raw provider result", async () => {
expect(success?.data).toHaveProperty("result")
})
test("interrupted progress publication remains in the terminal failure snapshot", async () => {
const { published, publisher } = capture("anthropic", { interruptProgress: true })
await Effect.runPromise(publisher.publish(call))
const exit = await Effect.runPromiseExit(
publisher.progress(call.id, {
structured: { phase: "visible" },
content: [{ type: "text", text: "visible" }],
}),
)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({
metadata: { phase: "visible" },
content: [{ type: "text", text: "visible" }],
})
})
test("failure before progress omits partial output fields", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
const failed = published.find((event) => event.type === "session.tool.failed.1")?.data
expect(failed).not.toHaveProperty("content")
expect(failed).not.toHaveProperty("metadata")
})
test("provider metadata is flattened using the route key", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
+46
View File
@@ -892,6 +892,52 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("persists the latest partial snapshot when a tool fails", () =>
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
failing_progress: Tool.make({
description: "Report progress and fail",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
Effect.gen(function* () {
yield* context.progress({
structured: { phase: "running" },
content: [{ type: "text", text: "before failure" }],
})
return yield* new ToolFailure({ message: "failed after progress" })
}),
}),
}, { codemode: false })
yield* admit(session, "Run failing progress")
responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()]
yield* session.resume(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Run failing progress" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-failing-progress",
state: {
status: "error",
structured: { phase: "running" },
content: [{ type: "text", text: "before failure" }],
error: { message: "failed after progress" },
},
},
],
},
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("executes the tool advertised before a registry reload", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -22,10 +22,10 @@ const it = testEffect(LayerNode.compile(LayerNode.group([Database.node, EventV2.
const timestamp = DateTime.makeUnsafe(1)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
const content = (text: string) => [{ type: "text" as const, text }]
const content = (text: string) => [{ type: "text" as const, text }] as const
describe("Tool.Progress", () => {
it.effect("projects durable progress and keeps final settlements durable", () =>
it.effect("keeps progress live-only and terminal settlements durable", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const service = yield* EventV2.Service
@@ -87,7 +87,7 @@ describe("Tool.Progress", () => {
state: { status: "running", structured: {}, content: [] },
})
yield* service.publish(SessionEvent.Tool.Progress, {
const progress = yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
assistantMessageID,
callID: "call-success",
@@ -95,7 +95,7 @@ describe("Tool.Progress", () => {
content: content("saved"),
})
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "running", structured: { phase: "checkpoint" }, content: content("saved") },
state: { status: "running", structured: {}, content: [] },
})
const success = yield* service.publish(SessionEvent.Tool.Success, {
@@ -123,6 +123,8 @@ describe("Tool.Progress", () => {
assistantMessageID,
callID: "call-failed",
error: { type: "unknown", message: "boom" },
metadata: { phase: "checkpoint" },
content: content("before failure"),
executed: false,
})
expect((yield* readAssistant).content[1]).toMatchObject({
@@ -133,6 +135,7 @@ describe("Tool.Progress", () => {
error: { type: "unknown", message: "boom" },
},
})
expect(Schema.is(SessionEvent.Durable)(progress)).toBe(false)
expect(Schema.is(SessionEvent.Durable)(success)).toBe(true)
expect(Schema.is(SessionEvent.Durable)(failed)).toBe(true)
@@ -143,7 +146,7 @@ describe("Tool.Progress", () => {
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1))
expect(rows.map((row) => row.type)).not.toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1))
}),
+1 -24
View File
@@ -1,7 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -460,25 +459,3 @@ describe("EditTool", () => {
),
)
})
test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"])
expect(source).toContain(
"absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.",
)
for (const todo of [
"Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.",
"Add formatter integration after V2 formatter runtime exists.",
"Publish watcher/file-edit events after V2 watcher integration exists.",
"Add snapshots / undo after design exists.",
"Add LSP notification and diagnostics after V2 LSP runtime exists.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})
+148 -13
View File
@@ -2,6 +2,7 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import { systemError } from "effect/PlatformError"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -28,6 +29,8 @@ const sessionID = SessionV2.ID.make("ses_patch_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined
let failRemoveTarget: string | undefined
let failRemoveErrorTarget: string | undefined
let failWriteTarget: string | undefined
let readsBeforeEditApproval = 0
let editApproved = false
let afterEditApproval = (): Effect.Effect<void> => Effect.void
@@ -65,6 +68,8 @@ const reset = () => {
assertions.length = 0
denyAction = undefined
failRemoveTarget = undefined
failRemoveErrorTarget = undefined
failWriteTarget = undefined
readsBeforeEditApproval = 0
editApproved = false
afterEditApproval = () => Effect.void
@@ -82,8 +87,33 @@ const filesystem = Layer.effect(
}).pipe(Effect.andThen(fs.readFile(target))),
remove: (target, options) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
return Effect.fail(
systemError({
_tag: "Unknown",
module: "FileSystem",
method: "remove",
description: "forced remove failure",
pathOrDescriptor: target,
}),
)
}
return fs.remove(target, options)
},
writeWithDirs: (target, content, mode) => {
if (failWriteTarget && path.basename(target) === failWriteTarget) {
return Effect.fail(
systemError({
_tag: "Unknown",
module: "FileSystem",
method: "writeWithDirs",
description: "forced write failure",
pathOrDescriptor: target,
}),
)
}
return fs.writeWithDirs(target, content, mode)
},
})
}),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
@@ -303,6 +333,26 @@ describe("PatchTool", () => {
),
)
it.live("moves a file without changing its contents", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const source = path.join(directory, "old.txt")
const destination = path.join(directory, "moved.txt")
yield* Effect.promise(() => fs.writeFile(source, "same\n"))
expect(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n same\n*** End Patch",
),
),
).toEqual({ type: "text", value: "Success. Updated the following files:\nM moved.txt" })
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("same\n")
}),
),
)
it.live("moves a symlink without deleting its target", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
@@ -488,10 +538,17 @@ describe("PatchTool", () => {
it.live("rejects an empty patch", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({
type: "error",
value: "patch rejected: empty patch",
})
for (const patchText of [
"*** Begin Patch\n*** End Patch",
" *** Begin Patch \n *** End Patch ",
"<<EOF\n*** Begin Patch\n*** End Patch\nEOF",
"*** Begin Patch\n*** Environment ID: remote\n*** End Patch",
]) {
expect(yield* executeTool(registry, call(patchText))).toEqual({
type: "error",
value: "patch rejected: empty patch",
})
}
}),
),
)
@@ -675,12 +732,18 @@ describe("PatchTool", () => {
Effect.gen(function* () {
const target = path.join(directory, "unchanged.txt")
yield* Effect.promise(() => fs.writeFile(target, "line1\nline2\n"))
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"),
),
).toMatchObject({ type: "error", value: expect.stringContaining("Failed to find expected lines") })
const settled = yield* settleTool(
registry,
call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"),
)
expect(settled.result).toEqual({
type: "error",
value: "patch verification failed: Failed to find expected lines in unchanged.txt:\nmissing",
})
expect(settled.error).toEqual({
type: "tool.execution",
message: "patch verification failed: Failed to find expected lines in unchanged.txt:\nmissing",
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n")
}),
),
@@ -718,12 +781,84 @@ describe("PatchTool", () => {
),
)
it.live("rejects a delete when the target file is missing", () =>
withTempTool((_directory, registry) =>
it.live("identifies a missing delete target", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")),
).toMatchObject({ type: "error", value: expect.stringContaining("patch verification failed") })
).toMatchObject({
type: "error",
value: expect.stringContaining(
`patch verification failed: Failed to read file to delete ${path.join(directory, "missing.txt")}: `,
),
})
}),
),
)
it.live("reports the failing destination and filesystem error", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "old.txt"), "before\n"))
failWriteTarget = "new.txt"
expect(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toEqual({
type: "error",
value: "Unable to apply patch at new.txt: forced write failure",
})
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "old.txt"), "utf8"))).toBe("before\n")
expect(yield* exists(path.join(directory, "new.txt"))).toBe(false)
}),
),
)
it.live("reports the successful prefix and filesystem error", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
failWriteTarget = "second.txt"
expect(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Add File: first.txt\n+first\n*** Add File: second.txt\n+second\n*** End Patch",
),
),
).toEqual({
type: "error",
value:
"Patch partially applied before failing at second.txt: forced write failure. Completed before failure: first.txt",
})
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "first.txt"), "utf8"))).toBe("first\n")
expect(yield* exists(path.join(directory, "second.txt"))).toBe(false)
}),
),
)
it.live("reports a destination written before move removal fails", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "old.txt"), "before\n"))
failRemoveErrorTarget = "old.txt"
expect(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toEqual({
type: "error",
value:
"Patch partially applied while moving old.txt to new.txt: wrote new.txt but failed to remove old.txt: forced remove failure",
})
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "old.txt"), "utf8"))).toBe("before\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "new.txt"), "utf8"))).toBe("after\n")
}),
),
)
+6 -2
View File
@@ -86,8 +86,12 @@ describe("search tools", () => {
const glob = yield* settleTool(registry, call("glob", { pattern: "*" }))
const grep = yield* settleTool(registry, call("grep", { pattern: "needle" }))
expect(glob.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }])
expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }])
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
}),
)
}),
+32 -18
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -159,6 +159,9 @@ const mixedOutputCommand = isWindows
? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100"
: "printf stdout; sleep 0.05; printf stderr >&2"
const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
const steadyProgressCommand = isWindows
? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
: "printf steady; sleep 3.4"
const bodyExitCommand = isWindows
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
: "printf body && exit 7"
@@ -462,6 +465,34 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
it.live(
"does not repeat unchanged shell progress",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const updates: ToolRegistry.Progress[] = []
yield* settleTool(registry, {
...call({ command: steadyProgressCommand }, "call-steady-progress"),
progress: (update) => Effect.sync(() => updates.push(update)),
})
expect(updates).toEqual([
{
structured: { truncated: false },
content: [{ type: "text", text: "steady" }],
},
])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 10_000 },
)
it.live("returns a useful timeout settlement", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -612,20 +643,3 @@ describe("ShellTool", () => {
),
)
})
test("keeps locked deferred parity TODOs visible", async () => {
const source = await fs.readFile(new URL("../src/tool/shell.ts", import.meta.url), "utf8")
for (const todo of [
"Port tree-sitter bash / PowerShell parser-based approval reduction.",
"Port BashArity reusable command-prefix approvals.",
"Replace token-based command-argument external-directory advisories with parser-based detection.",
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Persist job status and define restart recovery before exposing remote observation.",
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
"Revisit binary output handling if stdout/stderr decoding is text-only.",
"Stream full shell output into managed storage while retaining only a bounded in-memory preview.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})
+5 -2
View File
@@ -119,9 +119,12 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
}),
).toMatchObject({
).toEqual({
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: { structured: { name: "Effect" } },
output: {
structured: { name: "Effect", directory },
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
},
})
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
+24 -4
View File
@@ -35,7 +35,8 @@ const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID:
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID
const outputSessionID = (value: unknown) =>
Schema.decodeUnknownSync(Schema.Struct({ sessionID: SessionV2.ID }))(value).sessionID
const executionNode = makeGlobalNode({
service: SessionExecution.Service,
@@ -229,7 +230,17 @@ describe("SubagentTool", () => {
},
})
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
})
expect(settled.output?.structured).toEqual({
sessionID: outputSessionID(settled.output?.structured),
status: "completed",
})
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
}),
),
@@ -264,8 +275,15 @@ describe("SubagentTool", () => {
},
})
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
})
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({
parentID: parent.id,
@@ -361,8 +379,10 @@ describe("SubagentTool", () => {
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({
status: "running",
output: expect.stringContaining(`id: ${childID}`),
})
expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" })
expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) })
expect(settled.output?.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
+1 -1
View File
@@ -96,7 +96,7 @@ describe("WebFetchTool registration", () => {
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
structured: { contentType: "text/plain" },
content: [{ type: "text", text: "hello" }],
},
})
+1 -1
View File
@@ -244,7 +244,7 @@ describe("WebSearchTool registration", () => {
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results" },
structured: { provider: "parallel" },
content: [{ type: "text", text: "parallel results" }],
},
})
+1 -24
View File
@@ -1,7 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -363,25 +362,3 @@ describe("WriteTool", () => {
),
)
})
test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"])
expect(source).toContain(
"absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.",
)
for (const todo of [
"Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.",
"Add formatter integration after V2 formatter runtime exists.",
"Publish watcher/file-edit events after V2 watcher integration exists.",
"Add snapshots / undo after design exists.",
"Add LSP notification and diagnostics after V2 LSP runtime exists.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})
+2 -2
View File
@@ -69,7 +69,7 @@ serviceIt.live("persists sources in one KV value", () =>
const events = yield* EventV2.Service
const changed = yield* events
.subscribe(WellKnown.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
const entry = yield* wellknown.add(`${server.url.origin}/`)
expect(entry.origin).toBe(server.url.origin)
@@ -108,7 +108,7 @@ serviceIt.live("refreshes changed manifests", () =>
const changed = yield* events
.subscribe(WellKnown.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
update()
expect(yield* wellknown.refresh()).toBe(true)
expect(yield* Fiber.join(changed)).toHaveLength(1)
+5 -8
View File
@@ -3,8 +3,7 @@ export * as SessionEvent from "./session-event.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { Event } from "./event.js"
import { ToolContent } from "./llm.js"
import { FinishReason } from "./llm.js"
import { FinishReason, ToolContent } from "./llm.js"
import { Model } from "./model.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { FileAttachment } from "./prompt.js"
@@ -410,13 +409,9 @@ export namespace Tool {
})
export type Called = typeof Called.Type
/**
* Replayable bounded running-tool state. Tools should checkpoint semantic
* transitions or at a bounded cadence, not persist every stdout/stderr chunk.
*/
export const Progress = Event.durable({
/** Live replacement snapshot for a running tool. */
export const Progress = Event.ephemeral({
type: "session.tool.progress",
...options,
schema: {
...ToolBase,
structured: Schema.Record(Schema.String, Schema.Unknown),
@@ -445,6 +440,8 @@ export namespace Tool {
schema: {
...ToolBase,
error: SessionError.Error,
content: Schema.NonEmptyArray(ToolContent).pipe(optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
result: Schema.Unknown.pipe(optional),
executed: Schema.Boolean,
resultState: SessionMessage.ProviderState.pipe(optional),
+2 -1
View File
@@ -125,7 +125,6 @@ describe("public event manifest", () => {
"session.tool.input.started.1",
"session.tool.input.ended.1",
"session.tool.called.1",
"session.tool.progress.1",
"session.tool.success.1",
"session.tool.failed.1",
"session.reasoning.started.1",
@@ -152,6 +151,8 @@ describe("public event manifest", () => {
expect(EventManifest.Latest.has("session.usage.recorded")).toBe(false)
expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral")
expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral")
expect(SessionEvent.Tool.Progress.durability).toBe("ephemeral")
expect(EventManifest.Server.get("session.tool.progress")).toBe(SessionEvent.Tool.Progress)
expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false)
expect(EventManifest.ServerDefinitions).toContain(SessionEvent.UsageUpdated)
expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true)
+9 -22
View File
@@ -1,4 +1,4 @@
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { render, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { registerOpencodeSpinner } from "./component/register-spinner"
import { Deferred, Effect } from "effect"
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
@@ -36,6 +36,7 @@ import {
TuiPathsProvider,
TuiStartupProvider,
TuiTerminalEnvironmentProvider,
useTuiApp,
useTuiStartup,
type TuiApp,
} from "./context/runtime"
@@ -47,8 +48,7 @@ import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event"
import { ClientProvider, useClient } from "./context/client"
import { StartupLoading } from "./component/startup-loading"
import { DevToolsSidebar } from "./component/devtools-sidebar"
import { DevTools } from "./devtools"
import { DevToolsBar } from "./component/devtools-bar"
import { Reconnecting } from "./component/reconnecting"
import { DataProvider, useData } from "./context/data"
import { LocationProvider, useLocation } from "./context/location"
@@ -88,8 +88,6 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
registerOpencodeSpinner()
const appGlobalBindingCommands = [
@@ -257,12 +255,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const pluginRuntime = createPluginRuntime()
yield* Effect.tryPromise(async () => {
const appStarted = performance.now()
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const modeStarted = performance.now()
const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
themePerformance.set("Detect light/dark mode", `${(performance.now() - modeStarted).toFixed(2)} ms`)
if (renderer.isDestroyed) return
await render(() => {
@@ -351,7 +346,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<EditorContextProvider>
<PluginProvider packages={input.packages}>
<App
started={appStarted}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
@@ -409,11 +403,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { pair?: DialogPairCredentials; started: number }) {
function App(props: { pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
const config = useConfig()
const devtools = createMemo(() => config.data.debug?.devtools ?? false)
const devtools = createMemo(() => config.data.debug?.devtools ?? app.channel === "local")
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@@ -433,11 +428,6 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
const plugins = usePlugin()
const clipboard = useClipboard()
createEffect(() => {
if (!themeState.ready) return
themePerformance.set("Total", `${(performance.now() - props.started).toFixed(2)} ms`)
})
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
// the same problem on every refresh while still re-alerting if the state changes.
@@ -1110,9 +1100,6 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
: undefined
}
>
<Show when={config.data.debug?.timing}>
<TimeToFirstDraw />
</Show>
<box flexGrow={1} minHeight={0} flexDirection="row">
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
@@ -1141,10 +1128,10 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
<PluginSlot name="app" />
</Show>
</box>
<Show when={devtools()}>
<DevToolsSidebar />
</Show>
</box>
<Show when={devtools()}>
<DevToolsBar />
</Show>
<Show when={!startup.skipInitialLoading}>
<StartupLoading ready={plugins.ready} />
</Show>
+533
View File
@@ -0,0 +1,533 @@
import { TextAttributes } from "@opentui/core"
import { TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { open } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { monitorEventLoopDelay } from "node:perf_hooks"
import { createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type ParentProps } from "solid-js"
import { useClient } from "../context/client"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { useLocation } from "../context/location"
import { useRoute } from "../context/route"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { DevTools } from "../devtools"
import { usePlugin } from "../plugin/context"
import { errorMessage } from "../util/error"
const graphWidth = 23
const sampleIntervalMilliseconds = 2_000
const sampleRetentionMilliseconds = 30_000
const statusWindowMilliseconds = 6_000
type Panel = "server" | "theme" | "tools" | "ui"
type ProcessSample = Readonly<{ cpu: number; memory: number; delay: number; time: number }>
export type RuntimeStatus = "normal" | "medium" | "high"
export function DevToolsBar() {
const client = useClient()
const config = useConfig()
const data = useData()
const location = useLocation()
const route = useRoute()
const plugins = usePlugin()
const theme = useTheme()
const keymap = Keymap.use()
const dimensions = useTerminalDimensions()
const { themeV2, mode, supports, setMode } = theme
const elevatedTheme = theme.contextual("elevated").themeV2
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
const [dumpError, setDumpError] = createSignal<string>()
const [frontendSamples, setFrontendSamples] = createSignal<readonly ProcessSample[]>([])
const connected = createMemo(() => client.connection.status() === "connected")
const serverIndicator = createMemo(() => connectionIndicator(client.connection.status(), client.connection.attempt()))
const themePerformance = createMemo(
() => DevTools.data().find((group) => group.id === "theme-performance")?.entries ?? [],
)
const groups = createMemo(() => DevTools.data().filter((group) => group.id !== "theme-performance"))
const [server] = createResource(connected, async () => {
const [health, info] = await Promise.all([client.api.health.get(), client.api.server.get()])
return {
health,
address: info.urls[0] ? new URL(info.urls[0]).host : "Unknown",
}
})
const toggle = (next: Panel) => setPanel((current) => (current === next ? undefined : next))
const nextMode = () => (mode() === "dark" ? "light" : "dark")
const canSwitchMode = () => supports(nextMode())
const runtime = createMemo(() => runtimeStatus(frontendSamples()))
const timing = () => config.data.debug?.timing ?? false
const offEscape = keymap.intercept(
"key",
({ event }) => {
if (!panel() || event.name !== "escape") return
event.preventDefault()
event.stopPropagation()
setPanel()
},
{ priority: 10 },
)
onCleanup(offEscape)
onMount(() => {
const eventLoop = monitorEventLoopDelay({ resolution: 20 })
let frontendCPU = process.cpuUsage()
let frontendTime = performance.now()
let frontendReady = false
eventLoop.enable()
const sample = () => {
const now = performance.now()
const cpu = process.cpuUsage(frontendCPU)
frontendCPU = process.cpuUsage()
setFrontendSamples((samples) =>
[
...samples,
{
cpu: frontendReady ? cpuPercent(cpu.user + cpu.system, now - frontendTime) : 0,
memory: process.memoryUsage().rss,
delay: eventLoop.percentile(99) / 1_000_000,
time: now,
},
].filter((sample) => sample.time >= now - sampleRetentionMilliseconds),
)
eventLoop.reset()
frontendReady = true
frontendTime = now
}
sample()
const timer = setInterval(sample, sampleIntervalMilliseconds)
onCleanup(() => {
clearInterval(timer)
eventLoop.disable()
})
})
async function dump() {
setDumping(true)
setDumpPath()
setDumpError()
const routeData = route.data
const sessionID = routeData.type === "session" ? routeData.sessionID : undefined
const info = sessionID ? data.session.get(sessionID) : undefined
const sessionLocation =
info?.location ??
(location.current
? { directory: location.current.directory, workspaceID: location.current.workspaceID }
: undefined)
const details = server()
const backend = {
connected: connected(),
version: details?.health.version,
pid: details?.health.pid,
error: client.connection.error(),
}
const events = await (sessionID
? (async () => {
const events: { readonly created: number }[] = []
for await (const event of client.api.session.log({ sessionID, follow: false })) {
if (event.type !== "log.synced") events.push(event)
}
// Durable events stay in aggregate order even when their wall-clock timestamps differ.
client.connection.internal.history().forEach((event) => {
const index = events.findIndex((item) => item.created > event.created)
if (index === -1) {
events.push(event)
return
}
events.splice(index, 0, event)
})
return events.slice(-100)
})().catch(() => client.connection.internal.history())
: Promise.resolve([]))
const file = path.join(tmpdir(), `opencode-debug-${crypto.randomUUID()}.json`)
const output =
JSON.stringify(
{
backend,
session: sessionID
? {
...info,
id: sessionID,
projectID: info?.projectID ?? location.current?.project.id,
location: sessionLocation,
status: data.session.status(sessionID),
pending: data.session.pending.list(sessionID),
inputIDs: data.session.input.list(sessionID),
permissions: data.session.permission.list(sessionID) ?? [],
forms: data.session.form.list(sessionID) ?? [],
}
: undefined,
events,
mcp: {
servers: sessionLocation
? (data.location.mcp.server.list(sessionLocation) ?? [])
: (data.location.mcp.server.list() ?? []),
resources: sessionLocation
? (data.location.mcp.resource.list(sessionLocation) ?? [])
: (data.location.mcp.resource.list() ?? []),
},
plugins: {
ready: plugins.ready(),
list: plugins.list().map((plugin) => ({
name: "id" in plugin ? plugin.id : plugin.target,
...plugin,
})),
},
theme: {
name: theme.selected,
mode: theme.mode(),
},
},
null,
2,
) + "\n"
await open(file, "wx", 0o600)
.then((handle) => handle.writeFile(output).finally(() => handle.close()))
.then(
() => setDumpPath(file),
(error) => setDumpError(errorMessage(error)),
)
setDumping(false)
}
return (
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={themeV2.raise(themeV2.background.default)}>
<Show when={panel()}>
<box
position="absolute"
zIndex={2400}
left={0}
bottom={1}
width={dimensions().width}
height={Math.max(0, dimensions().height - 1)}
backgroundColor="transparent"
onMouseUp={() => setPanel()}
/>
</Show>
<BarItem active={panel() === "server"} onClick={() => toggle("server")}>
<text
fg={
panel() === "server"
? themeV2.text.action.primary.focused
: serverIndicator().state === "connected"
? themeV2.text.feedback.success.default
: serverIndicator().state === "disconnected"
? themeV2.text.feedback.error.default
: themeV2.text.default
}
>
{serverIndicator().icon}
</text>
<text
fg={
panel() === "server"
? themeV2.text.action.primary.focused
: serverIndicator().state === "disconnected"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
}
>
{" "}
Server
</text>
<Show when={panel() === "server"}>
<PanelBox>
<PanelTitle>Server</PanelTitle>
<Row label="Status" value={connected() ? "Connected" : client.connection.status()} />
<Show when={client.connection.attempt() > 0}>
<Row label="Reconnect" value={String(client.connection.attempt())} />
</Show>
<Show when={client.connection.error()}>{(error) => <Row label="Last error" value={error()} />}</Show>
<Show when={server()}>
{(value) => (
<>
<Row label="Version" value={value().health.version} />
<Row label="PID" value={String(value().health.pid)} />
<Row label="Address" value={value().address} />
</>
)}
</Show>
<Show when={server.error}>
<text fg={elevatedTheme.text.feedback.error.default}>Server details unavailable</text>
</Show>
</PanelBox>
</Show>
</BarItem>
<BarItem active={panel() === "ui"} onClick={() => toggle("ui")}>
<text
fg={
panel() === "ui"
? themeV2.text.action.primary.focused
: runtime() === "high"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
}
>
{statusIcon(runtime())}
</text>
<text
fg={
panel() === "ui"
? themeV2.text.action.primary.focused
: runtime() === "high"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
}
>
{" "}
UI
</text>
<Show when={panel() === "ui"}>
<PanelBox>
<PanelTitle>UI</PanelTitle>
<Row label="Status" value={runtime()} />
<ProcessStat
label="Loop"
values={frontendSamples().map((sample) => sample.delay)}
unit=" ms"
decimals={1}
/>
<ProcessStat label="CPU" values={frontendSamples().map((sample) => sample.cpu)} unit="%" />
<ProcessStat
label="Memory"
values={frontendSamples().map((sample) => sample.memory / 1024 / 1024)}
unit=" MB"
decimals={0}
/>
</PanelBox>
</Show>
</BarItem>
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
<text fg={panel() === "theme" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Theme</text>
<Show when={panel() === "theme"}>
<PanelBox>
<PanelTitle>Theme</PanelTitle>
<Row label="Name" value={theme.selected} />
<Row label="Mode" value={mode()} />
<For each={themePerformance()}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
<Show when={canSwitchMode()}>
<Action onClick={() => setMode(nextMode())} hoverBackground>
Switch to {nextMode()}
</Action>
</Show>
</PanelBox>
</Show>
</BarItem>
<BarItem active={panel() === "tools"} onClick={() => toggle("tools")}>
<text fg={panel() === "tools" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Tools</text>
<Show when={panel() === "tools"}>
<PanelBox>
<PanelTitle>Tools</PanelTitle>
<Action onClick={() => void dump()} disabled={dumping()} hoverBackground>
{dumping() ? "Writing debug snapshot..." : "Write debug snapshot"}
</Action>
<Show when={dumpPath()}>
{(file) => (
<text fg={elevatedTheme.text.subdued} wrapMode="word">
{file()}
</text>
)}
</Show>
<Show when={dumpError()}>
{(error) => (
<text fg={elevatedTheme.text.feedback.error.default} wrapMode="word">
{error()}
</text>
)}
</Show>
<box marginTop={1}>
<text fg={elevatedTheme.text.default} attributes={TextAttributes.BOLD}>
Render
</text>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, timing: !timing() }
})
}
hoverBackground
>
{timing() ? "[x]" : "[ ]"} Time to first draw
</Action>
</box>
<For each={groups()}>
{(group) => (
<box marginTop={1}>
<text fg={elevatedTheme.text.default} attributes={TextAttributes.BOLD}>
{group.title}
</text>
<For each={group.entries}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
</box>
)}
</For>
</PanelBox>
</Show>
</BarItem>
<box flexGrow={1} minWidth={0}>
<TimeToFirstDraw visible={timing()} width="100%" fg={themeV2.text.subdued} label="Time to first draw" />
</box>
</box>
)
}
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
const { themeV2 } = useTheme()
const renderer = useRenderer()
return (
<box
position="relative"
zIndex={props.active ? 2500 : undefined}
height={1}
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={props.active ? themeV2.background.action.primary.focused : undefined}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onClick()
}}
>
<box flexDirection="row">{props.children}</box>
</box>
)
}
function PanelBox(props: ParentProps) {
const { themeV2 } = useTheme().contextual("elevated")
const renderer = useRenderer()
return (
<box
position="absolute"
zIndex={2600}
bottom={1}
left={0}
width={42}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={themeV2.background.default}
flexDirection="column"
onMouseUp={(event) => {
if (renderer.getSelection()?.getSelectedText()) return
event.stopPropagation()
}}
>
{props.children}
</box>
)
}
function PanelTitle(props: ParentProps) {
const { themeV2 } = useTheme().contextual("elevated")
return (
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
</text>
)
}
function Row(props: { label: string; value: string }) {
const { themeV2 } = useTheme().contextual("elevated")
return (
<box flexDirection="row">
<text fg={themeV2.text.subdued}>{props.label}</text>
<box flexGrow={1} />
<text fg={themeV2.text.default}>{props.value}</text>
</box>
)
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const { themeV2 } = useTheme().contextual("elevated")
const [hovered, setHovered] = createSignal(false)
return (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? themeV2.background.action.primary.hovered : undefined
}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseUp={(event) => {
event.stopPropagation()
if (!props.disabled) props.onClick()
}}
>
<text fg={props.disabled ? themeV2.text.subdued : themeV2.text.action.primary.default}>{props.children}</text>
</box>
)
}
function cpuPercent(microseconds: number, milliseconds: number) {
if (milliseconds <= 0) return 0
return Math.max(0, microseconds / (milliseconds * 10))
}
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
const { themeV2 } = useTheme().contextual("elevated")
const value = () => {
const value = props.values.at(-1)
if (value === undefined) return "--"
return `${value.toFixed(props.decimals ?? 1)}${props.unit}`
}
return (
<box flexDirection="row">
<box width={7}>
<text fg={themeV2.text.subdued}>{props.label}</text>
</box>
<box flexGrow={1}>
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{brailleGraph(props.values)}</text>
</box>
<box width={8} alignItems="flex-end">
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{value()}</text>
</box>
</box>
)
}
export function runtimeStatus(samples: readonly Readonly<{ delay: number; time: number }>[]): RuntimeStatus {
const latest = samples.at(-1)?.time
if (latest === undefined) return "normal"
const delay = Math.max(
0,
...samples.filter((sample) => sample.time > latest - statusWindowMilliseconds).map((sample) => sample.delay),
)
if (delay >= 100) return "high"
if (delay >= 20) return "medium"
return "normal"
}
export function statusIcon(status: RuntimeStatus) {
if (status === "high") return "●"
if (status === "medium") return "⦿"
return "○"
}
export function connectionIndicator(status: "connected" | "connecting" | "reconnecting", attempt: number) {
if (status === "connected") return { state: "connected" as const, icon: "✓" }
if (status === "reconnecting" && attempt >= 3) return { state: "disconnected" as const, icon: "×" }
return { state: "reconnecting" as const, icon: "↻" }
}
export function brailleGraph(values: readonly number[], width = graphWidth) {
const min = Math.min(...values)
const range = Math.max(...values) - min
const points = [...Array<number | undefined>(width * 2 - values.length).fill(values.at(0)), ...values].slice(
-width * 2,
)
const dots = [
[6, 2, 1, 0],
[7, 5, 4, 3],
]
return Array.from({ length: width }, (_, index) => {
const bits = [points[index * 2], points[index * 2 + 1]].reduce<number>((result, value, column) => {
if (value === undefined) return result
const height = 1 + Math.round((range === 0 ? 0 : (value - min) / range) * 3)
return dots[column].slice(0, height).reduce<number>((bits, dot) => bits | (1 << dot), result)
}, 0)
return String.fromCodePoint(0x2800 + bits)
}).join("")
}
@@ -1,65 +0,0 @@
import { TextAttributes } from "@opentui/core"
import { createSignal, For } from "solid-js"
import { useTheme } from "../context/theme"
import { DevTools } from "../devtools"
export function DevToolsSidebar() {
const { themeV2, mode, supports, setMode } = useTheme().contextual("elevated")
const [modeHovered, setModeHovered] = createSignal(false)
const nextMode = () => (mode() === "dark" ? "light" : "dark")
const canSwitchMode = () => supports(nextMode())
return (
<box
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
backgroundColor={themeV2.background.default}
>
<box flexShrink={0} marginBottom={1}>
<box marginBottom={1}>
<text fg={themeV2.text.action.primary.default} attributes={TextAttributes.BOLD}>
Theme
</text>
</box>
<box flexDirection="row">
<text fg={themeV2.text.subdued}>Mode</text>
<box flexGrow={1} />
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={modeHovered() && canSwitchMode() ? themeV2.background.action.primary.hovered : undefined}
onMouseOver={() => setModeHovered(canSwitchMode())}
onMouseOut={() => setModeHovered(false)}
onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined}
>
<text fg={canSwitchMode() ? themeV2.text.default : themeV2.text.subdued}>{mode()}</text>
</box>
</box>
</box>
<For each={DevTools.data()}>
{(group) => (
<box flexShrink={0} marginBottom={1}>
<box marginBottom={1}>
<text fg={themeV2.text.action.primary.default} attributes={TextAttributes.BOLD}>
{group.title}
</text>
</box>
<For each={group.entries}>
{(entry) => (
<box flexDirection="row">
<text fg={themeV2.text.subdued}>{entry.key}</text>
<box flexGrow={1} />
<text fg={themeV2.text.default}>{String(entry.value)}</text>
</box>
)}
</For>
</box>
)}
</For>
</box>
)
}
+3 -3
View File
@@ -35,7 +35,7 @@ const settings: Setting[] = [
title: "Animations",
category: "Appearance",
path: ["animations"],
default: true,
default: false,
values: [false, true],
labels: ["off", "on"],
},
@@ -223,10 +223,10 @@ const settings: Setting[] = [
labels: ["off", "on"],
},
{
title: "Timing",
title: "DevTools: Timing",
category: "Debug",
path: ["debug", "timing"],
default: false,
default: true,
values: [false, true],
labels: ["off", "on"],
},
+10 -7
View File
@@ -1300,11 +1300,16 @@ export function Prompt(props: PromptProps) {
const highlight = createMemo(() => {
if (leader()) return themeV2.border.default
if (store.mode === "shell") return themeV2.background.action.primary.default
if (store.mode === "shell") return themeV2.text.action.primary.selected
const agent = local.agent.current()
if (!agent) return themeV2.border.default
return local.agent.color(agent.id)
})
const agentLabel = createMemo(() => {
if (store.mode === "shell") return "Shell"
const agent = local.agent.current()
return agent ? Locale.titlecase(agent.id) : undefined
})
const showVariant = createMemo(() => {
const variants = local.model.variant.list()
@@ -1313,7 +1318,7 @@ export function Prompt(props: PromptProps) {
return !!current
})
const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled)
const agentMetaAlpha = createFadeIn(() => store.mode === "shell" || !!local.agent.current(), animationsEnabled)
const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
const variantMetaAlpha = createFadeIn(
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
@@ -1460,12 +1465,10 @@ export function Prompt(props: PromptProps) {
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<Show when={local.agent.current()} fallback={<box height={1} />}>
{(agent) => (
<Show when={agentLabel()} fallback={<box height={1} />}>
{(label) => (
<>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)}
</text>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
<text fg={fadeColor(themeV2.text.subdued, agentMetaAlpha())}>auto</text>
</Show>
+4 -1
View File
@@ -136,6 +136,9 @@ export const Info = Schema.Struct({
footer: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide persistent activity, model, usage, and context details in the footer",
}),
splash: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide the entry and exit splash banners",
}),
mono: Schema.optional(Schema.Boolean).annotate({
description: "Use monochrome ASCII output",
}),
@@ -148,7 +151,7 @@ export const Info = Schema.Struct({
).annotate({ description: "In-product guidance settings" }),
debug: Schema.optional(
Schema.Struct({
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools sidebar" }),
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }),
}),
).annotate({ description: "Debugging settings" }),
+2 -2
View File
@@ -643,8 +643,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: match.state.status === "running" ? match.state.structured : {},
content: match.state.status === "running" ? match.state.content : [],
structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}),
content: event.data.content ?? (match.state.status === "running" ? match.state.content : []),
result: event.data.result,
}
match.executed = event.data.executed || match.executed === true
+5 -19
View File
@@ -162,7 +162,6 @@ const themeContext = createSimpleContext({
let systemThemeMode: "dark" | "light" | undefined
let hasResolvedSystemTheme = false
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
const started = performance.now()
return renderer
.getPalette({ size: 16 })
.then((colors: TerminalColors) => {
@@ -186,7 +185,6 @@ const themeContext = createSimpleContext({
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
})
.finally(() => themePerformance.set("Resolve system palette", duration(performance.now() - started)))
}
let systemRefreshRunning = false
@@ -272,14 +270,10 @@ const themeContext = createSimpleContext({
themeRefreshTimeouts.length = 0
})
const initStarted = performance.now()
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
const file = createMemo(() => {
const started = performance.now()
const result = migrateV1(source())
themePerformance.set("Convert V1 to V2", duration(performance.now() - started))
return result
})
const file = createMemo(() => migrateV1(source()))
const modes = createMemo(() => themeModes(file()))
const mode = () => {
const supported = modes()
@@ -287,12 +281,9 @@ const themeContext = createSimpleContext({
return supported[0] ?? store.mode
}
const values = createMemo(() => resolveTheme(source(), mode()))
const valuesV2 = createMemo(() => {
const resolveStarted = performance.now()
const result = resolveThemeFile(file(), mode(), sourceName())
themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted))
return result
})
const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName()))
valuesV2()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const themeV2 = createComponentTheme(valuesV2, mode)
const contextsV2 = {
elevated: createComponentTheme(() => {
@@ -374,11 +365,6 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName }
</themeContext.context.Provider>
)
}
function duration(milliseconds: number) {
return `${milliseconds.toFixed(2)} ms`
}
export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
const renderer = useRenderer()
const retained = new Set<SyntaxStyle>()
+12 -1
View File
@@ -684,6 +684,13 @@ export function RunSettingsBody(props: {
keywords: `footer status activity model context usage ${props.settings().footer}`,
key: "footer",
},
{
category: "Terminal",
display: "Splash",
footer: saving() === "splash" ? "saving" : props.settings().splash,
keywords: `splash entry exit banner ${props.settings().splash}`,
key: "splash",
},
{
category: "Terminal",
display: "Monochrome UI",
@@ -1062,7 +1069,11 @@ export function RunModelSelectBody(props: {
>
<RunFooterMenu
theme={props.theme}
items={controller.items}
items={() =>
controller.query().trim()
? controller.items().map((item) => ({ ...item, footer: item.providerName }))
: controller.items()
}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={() => PANEL_LIST_ROWS}
+16 -5
View File
@@ -102,6 +102,11 @@ type RunFooterOptions = {
subscribeThemeSignal: (listener: () => void) => () => void
}
export function resolveRunAgent(agents: RunAgent[], current: string | undefined) {
const selectable = agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
return selectable.find((agent) => agent.id === current) ?? selectable.at(0)
}
const PERMISSION_ROWS = 12
const FORM_ROWS = 14
const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
@@ -252,13 +257,15 @@ export class RunFooter implements FooterApi {
const [providers, setProviders] = createSignal<RunProvider[] | undefined>()
this.providers = providers
this.setProviders = setProviders
const [currentAgentID, setCurrentAgentID] = createSignal(options.agent)
this.currentAgentID = currentAgentID
const [selectedAgentID, setCurrentAgentID] = createSignal(options.agent)
const currentAgent = () => resolveRunAgent(this.agents(), selectedAgentID())
this.currentAgentID = () => currentAgent()?.id ?? selectedAgentID()
this.setCurrentAgentID = setCurrentAgentID
this.currentAgent = () => {
const agent = currentAgentID()
if (!agent) return "Default"
return this.agents().find((item) => item.id === agent)?.name ?? Locale.titlecase(agent)
const agent = currentAgent()
if (agent) return agent.name
const selected = selectedAgentID()
return selected ? Locale.titlecase(selected) : "Default"
}
const [currentModel, setCurrentModel] = createSignal<RunInput["model"]>(options.model)
this.currentModel = currentModel
@@ -609,6 +616,10 @@ export class RunFooter implements FooterApi {
return this.theme()
}
public currentMiniSettings(): MiniSettings {
return this.miniSettings()
}
private destroyTheme(theme: RunTheme): void {
const index = this.themes.indexOf(theme)
if (index === -1) {
+1
View File
@@ -90,6 +90,7 @@ export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }):
shell_output: config?.mini?.shell_output ?? "hide",
turn_summary: config?.mini?.turn_summary ?? "show",
footer: config?.mini?.footer ?? "show",
splash: config?.mini?.splash ?? "show",
mono: config?.mini?.mono ?? false,
}
}
+13 -10
View File
@@ -196,13 +196,15 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
renderer,
state,
"entry",
entrySplash({
...meta,
theme: theme.splash,
showSession: splash.showSession,
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
mono,
}),
miniSettings.splash === "show"
? entrySplash({
...meta,
theme: theme.splash,
showSession: splash.showSession,
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
mono,
})
: undefined,
)
await renderer.idle().catch(() => {})
@@ -224,7 +226,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
history: input.history,
theme,
mono,
wrote,
// The transcript always starts one row below the terminal's prior output,
// even when the entry splash itself is hidden.
wrote: wrote || miniSettings.splash === "hide",
tuiConfig,
miniSettings: {
current: miniSettings,
@@ -306,8 +310,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
try {
await footer.idle().catch(() => {})
const show = renderer.isDestroyed ? false : next.showExit
if (!renderer.isDestroyed && show) {
if (!renderer.isDestroyed && next.showExit && footer.currentMiniSettings().splash === "show") {
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
wroteExit = queueSplash(
+10 -8
View File
@@ -837,8 +837,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
? {
status: "error",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: part && part.state.status !== "streaming" ? part.state.structured : {},
content: part && part.state.status !== "streaming" ? part.state.content : [],
structured:
event.data.metadata ?? (part && part.state.status !== "streaming" ? part.state.structured : {}),
content: event.data.content ?? (part && part.state.status !== "streaming" ? part.state.content : []),
error: event.data.error,
result: event.data.result,
}
@@ -957,15 +958,16 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (pendingCalls.has(key)) pendingCalls.set(key, event.data.input)
return
}
if (event.type === "session.tool.failed") {
pendingCalls.delete(sourceKey(event.data.assistantMessageID, event.data.callID))
if (
event.type !== "session.tool.progress" &&
event.type !== "session.tool.success" &&
event.type !== "session.tool.failed"
)
return
}
if (event.type !== "session.tool.progress" && event.type !== "session.tool.success") return
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const pending = pendingCalls.get(key)
if (event.type === "session.tool.success") pendingCalls.delete(key)
const found = childSessionID(record(event.data.structured))
if (event.type !== "session.tool.progress") pendingCalls.delete(key)
const found = childSessionID(record(event.type === "session.tool.failed" ? event.data.metadata : event.data.structured))
if (!found) return
const child = admitChild(found.sessionID)
if (!child) return
+3 -2
View File
@@ -1084,8 +1084,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
? {
status: "error",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: part && part.state.status !== "streaming" ? part.state.structured : {},
content: part && part.state.status !== "streaming" ? part.state.content : [],
structured:
event.data.metadata ?? (part && part.state.status !== "streaming" ? part.state.structured : {}),
content: event.data.content ?? (part && part.state.status !== "streaming" ? part.state.content : []),
error: event.data.error,
result: event.data.result,
}
+1
View File
@@ -393,6 +393,7 @@ export type MiniSettings = {
shell_output: "show" | "hide"
turn_summary: "show" | "hide"
footer: "show" | "hide"
splash: "show" | "hide"
mono: boolean
}
@@ -16,7 +16,7 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov
const client = useClient()
const route = useRoute()
const toast = useToast()
const [pending, setPending] = createSignal(false)
const [pending, setPending] = createSignal(!!props.messageID)
const fork = async (messageID?: string) => {
setPending(true)
+1 -19
View File
@@ -762,23 +762,6 @@ export function Session() {
options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking)
: await (async () => {
if (options.debug) {
const events: { readonly created: number }[] = []
for await (const event of client.api.session.log({ sessionID: sessionData.id, follow: false })) {
if (event.type !== "log.synced") events.push(event)
}
// Durable events stay in aggregate order even when their wall-clock timestamps differ.
client.connection.internal.history().forEach((event) => {
const index = events.findIndex((item) => item.created > event.created)
if (index === -1) {
events.push(event)
return
}
events.splice(index, 0, event)
})
return JSON.stringify({ info: sessionData, events }, null, 2) + EOL
}
const messages: unknown[] = []
let cursor: string | undefined
do {
@@ -2669,8 +2652,7 @@ function WebFetch(props: ToolProps) {
function WebSearch(props: ToolProps) {
return (
<InlineTool icon="◈" pending="Searching web..." complete={stringValue(props.input.query)} part={props.part}>
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "}
<Show when={finiteNumber(props.metadata.numResults)}>({finiteNumber(props.metadata.numResults)} results)</Show>
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"
</InlineTool>
)
}
+3 -47
View File
@@ -9,11 +9,11 @@ export type ExportFormat = "markdown" | "json"
export type DialogExportOptionsProps = {
defaultThinking: boolean
onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; debug: boolean; thinking: boolean }) => void
onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean }) => void
onCancel?: () => void
}
type Active = ExportFormat | "debug" | "thinking" | "copy" | "export"
type Active = ExportFormat | "thinking" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
@@ -21,7 +21,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
debug: false,
thinking: props.defaultThinking,
active: "markdown" as Active,
})
@@ -30,7 +29,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
props.onConfirm?.({
action,
format: store.format,
debug: store.debug,
thinking: store.thinking,
})
@@ -39,7 +37,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
setStore("format", store.active)
return
}
if (store.active === "debug") setStore("debug", !store.debug)
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "copy" || store.active === "export") confirm(store.active)
}
@@ -55,7 +52,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const order: Active[] =
store.format === "markdown"
? ["markdown", "json", "thinking", "copy", "export"]
: ["markdown", "json", "debug", "copy", "export"]
: ["markdown", "json", "copy", "export"]
setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
},
},
@@ -156,46 +153,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
</text>
</box>
</Show>
<Show when={store.format === "json"}>
<box
flexDirection="row"
gap={1}
backgroundColor={
store.active === "debug"
? themeV2.background.formfield.focused
: store.debug
? themeV2.background.formfield.selected
: themeV2.background.formfield.default
}
onMouseUp={() => {
setStore("active", "debug")
setStore("debug", !store.debug)
}}
>
<text
fg={
store.active === "debug"
? themeV2.text.formfield.focused
: store.debug
? themeV2.text.formfield.selected
: themeV2.text.formfield.default
}
>
{store.debug ? "[x]" : "[ ]"}
</text>
<text
fg={
store.active === "debug"
? themeV2.text.formfield.focused
: store.debug
? themeV2.text.formfield.selected
: themeV2.text.formfield.default
}
>
Events (debug)
</text>
</box>
</Show>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={4}
@@ -230,7 +187,6 @@ DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) =>
return new Promise<{
action: "copy" | "export"
format: ExportFormat
debug: boolean
thinking: boolean
} | null>((resolve) => {
dialog.replace(
-1
View File
@@ -2332,7 +2332,6 @@ test("settles pending tools when a live failure arrives", async () => {
id: "evt_progress_1",
created: 0,
type: "session.tool.progress",
durable: durable("session-1", 5),
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
-19
View File
@@ -1,19 +0,0 @@
import { expect, test } from "bun:test"
import { DevTools } from "../src/devtools"
test("registers and updates grouped DevTools data", () => {
const group = DevTools.register({ id: "test", title: "Test data" })
group.set("Duration", "1.00 ms")
group.set("Duration", "2.00 ms")
group.set("Count", 2)
expect(DevTools.data().find((item) => item.id === "test")).toEqual({
id: "test",
title: "Test data",
entries: [
{ key: "Duration", value: "2.00 ms" },
{ key: "Count", value: 2 },
],
})
})
@@ -56,7 +56,14 @@ test("down opens subagents from an empty prompt", async () => {
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })}
miniSettings={() => ({
thinking: "hide",
shell_output: "hide",
turn_summary: "show",
footer: "show",
splash: "show",
mono: false,
})}
mono={false}
onSubmit={() => true}
onPermissionReply={() => {}}
+15 -2
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { coalesceProgressCommit } from "../../src/mini/footer"
import type { StreamCommit } from "../../src/mini/types"
import { coalesceProgressCommit, resolveRunAgent } from "../../src/mini/footer"
import type { RunAgent, StreamCommit } from "../../src/mini/types"
function progress(input: Partial<StreamCommit> = {}): StreamCommit {
return {
@@ -23,3 +23,16 @@ test("coalesces progress only within the same message and tool state", () => {
progress({ text: "onetwo", directory: "/latest" }),
)
})
test("resolves the first selectable agent when none is selected", () => {
const agents: RunAgent[] = [
{ id: "task", name: "Task", mode: "subagent", hidden: false },
{ id: "secret", name: "Secret", mode: "primary", hidden: true },
{ id: "build", name: "Build", mode: "primary", hidden: false },
{ id: "plan", name: "Plan", mode: "primary", hidden: false },
]
expect(resolveRunAgent(agents, undefined)?.id).toBe("build")
expect(resolveRunAgent(agents, "plan")?.id).toBe("plan")
expect(resolveRunAgent(agents, "missing")?.id).toBe("build")
})
+52 -5
View File
@@ -136,7 +136,14 @@ async function renderFooter(
const [state, setState] = footerState(input.state)
const config = input.tuiConfig ?? tuiConfig
const [miniSettings] = createSignal<MiniSettings>(
input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false },
input.miniSettings ?? {
thinking: "hide",
shell_output: "hide",
turn_summary: "show",
footer: "show",
splash: "show",
mono: false,
},
)
function Harness() {
return (
@@ -471,6 +478,7 @@ test("direct settings panel changes Mini transcript preferences", async () => {
shell_output: "hide",
turn_summary: "show",
footer: "show",
splash: "show",
mono: false,
})
const app = await testRender(
@@ -499,6 +507,7 @@ test("direct settings panel changes Mini transcript preferences", async () => {
expect(frame).toContain("Shell")
expect(frame).toContain("Turn summary")
expect(frame).toContain("Footer details")
expect(frame).toContain("Splash")
expect(frame).toContain("Monochrome UI")
expect(frame).toContain("left/right change")
expect(frame).not.toMatch(/[^\x00-\x7F]/)
@@ -506,16 +515,35 @@ test("direct settings panel changes Mini transcript preferences", async () => {
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })
expect(settings()).toEqual({
thinking: "show",
shell_output: "hide",
turn_summary: "show",
footer: "show",
splash: "show",
mono: false,
})
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide", footer: "show", mono: false })
expect(settings()).toEqual({
thinking: "show",
shell_output: "hide",
turn_summary: "hide",
footer: "show",
splash: "show",
mono: false,
})
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings().splash).toBe("hide")
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
@@ -1169,7 +1197,14 @@ test("direct footer shows authoritative pending work while running", async () =>
},
]}
theme={() => RUN_THEME_FALLBACK}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })}
miniSettings={() => ({
thinking: "hide",
shell_output: "hide",
turn_summary: "show",
footer: "show",
splash: "show",
mono: false,
})}
mono={false}
onSubmit={() => true}
onPermissionReply={() => {}}
@@ -1357,6 +1392,7 @@ test("direct footer hides routine activity and shows explicit notices", async ()
shell_output: "hide",
turn_summary: "show",
footer: "hide",
splash: "show",
mono: true,
},
mono: true,
@@ -1462,7 +1498,10 @@ test("direct permission rejection submits through keymap return binding", async
})
test("direct model panel renders current model selector", async () => {
const [providers] = createSignal<RunProvider[] | undefined>([provider()])
const [providers] = createSignal<RunProvider[] | undefined>([
provider(),
{ id: "openai", name: "OpenAI", models: { "gpt-5": model({ id: "gpt-5", name: "GPT-5" }) } },
])
const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
const app = await testRender(
@@ -1499,6 +1538,14 @@ test("direct model panel renders current model selector", async () => {
expect(frame).not.toContain("┃")
expect(frame).not.toContain("Old Model")
expectPaletteList(list, 2)
"gpt-5".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
const search = app.captureCharFrame()
expect(search.match(/GPT-5/g)).toHaveLength(2)
expect(search).toContain("opencode")
expect(search).toContain("OpenAI")
} finally {
app.renderer.destroy()
}
+10 -1
View File
@@ -106,17 +106,26 @@ describe("run runtime boot", () => {
shell_output: "hide",
turn_summary: "show",
footer: "show",
splash: "show",
mono: false,
})
expect(
resolveMiniSettings({
mini: { thinking: "show", shell_output: "show", turn_summary: "hide", footer: "hide", mono: true },
mini: {
thinking: "show",
shell_output: "show",
turn_summary: "hide",
footer: "hide",
splash: "hide",
mono: true,
},
}),
).toEqual({
thinking: "show",
shell_output: "show",
turn_summary: "hide",
footer: "hide",
splash: "hide",
mono: true,
})
})
@@ -2044,7 +2044,6 @@ describe("V2 mini transport", () => {
id: "evt_progress",
created: 3,
type: "session.tool.progress",
durable: durable("ses_1", 2),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
@@ -2063,6 +2062,8 @@ describe("V2 mini transport", () => {
assistantMessageID: "msg_progress",
callID: "call_progress",
error: { type: "unknown", message: "boom" },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial" }],
executed: true,
},
})
@@ -2844,6 +2845,70 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("discovers a subagent from its terminal failure snapshot", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events], messages: { ses_child_failed: [] } })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
events.push({
id: "evt_failed_subagent_input",
created: 1,
type: "session.tool.input.started",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
callID: "call_failed_subagent",
name: "subagent",
},
})
events.push({
id: "evt_failed_subagent_called",
created: 2,
type: "session.tool.called",
durable: durable("ses_1", 1),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
callID: "call_failed_subagent",
input: { agent: "explore", description: "Inspect failure", prompt: "inspect" },
executed: true,
},
})
events.push({
id: "evt_failed_subagent",
created: 3,
type: "session.tool.failed",
durable: durable("ses_1", 2),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
callID: "call_failed_subagent",
error: { type: "unknown", message: "subagent failed" },
metadata: { sessionID: "ses_child_failed", status: "running" },
executed: true,
},
})
while (!states().some((state) => state.tabs.some((tab) => tab.sessionID === "ses_child_failed")))
await Bun.sleep(0)
expect(states().at(-1)?.tabs).toMatchObject([
{
sessionID: "ses_child_failed",
label: "Explore",
description: "Inspect failure",
},
])
await transport.close()
})
test("discovers current subagents from progress and reduces descendant tool state", async () => {
const events = feed()
events.push(connected())
@@ -2885,7 +2950,6 @@ describe("V2 mini transport", () => {
id: "evt_subagent_progress",
created: 3,
type: "session.tool.progress",
durable: durable("ses_1", 2),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_subagent",
@@ -2937,7 +3001,6 @@ describe("V2 mini transport", () => {
id: "evt_child_tool_progress",
created: 6,
type: "session.tool.progress",
durable: durable("ses_child_progress", 2),
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
@@ -2968,6 +3031,8 @@ describe("V2 mini transport", () => {
assistantMessageID: "msg_child_tool",
callID: "call_child_shell",
error: { type: "unknown", message: "child boom" },
metadata: { checkpoint: "child" },
content: [{ type: "text", text: "child partial" }],
executed: true,
},
})
+24
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { normalizeTool, toolInlineInfo, toolOutputText, toolPath, toolScroll } from "../../src/mini/tool"
import { canonicalToolPart } from "./fixture/tool-part"
describe("Mini tool presentation", () => {
test("uses V2 shell output without the model-facing status", () => {
@@ -105,6 +106,29 @@ describe("Mini tool presentation", () => {
).toBe('→ Skill "effect"')
})
test("renders compact search metadata", () => {
expect(
toolInlineInfo(
canonicalToolPart("glob", {
status: "completed",
input: { pattern: "*.ts" },
structured: { count: 3 },
content: [],
}),
).description,
).toBe("3 matches")
expect(
toolInlineInfo(
canonicalToolPart("grep", {
status: "completed",
input: { pattern: "needle" },
structured: { matches: 1 },
content: [],
}),
).description,
).toBe("1 match")
})
test("keeps segment-safe contained tool paths relative", () => {
expect(toolPath("..cache/result.txt", { directory: "/work/project" })).toBe("..cache/result.txt")
expect(toolPath("../shared/result.txt", { directory: "/work/project" })).toBe("/work/shared/result.txt")
+201 -52
View File
@@ -13,8 +13,10 @@ export class BoundaryError extends Schema.TaggedErrorClass<BoundaryError>()("Pat
export class InvalidHunkError extends Schema.TaggedErrorClass<InvalidHunkError>()("Patch.InvalidHunkError", {
line: Schema.String,
lineNumber: Schema.Number,
reason: Schema.optional(Schema.String),
}) {
override get message() {
if (this.reason) return `Invalid hunk at line ${this.lineNumber}: ${this.reason}`
return `Invalid hunk at line ${this.lineNumber}: '${this.line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`
}
}
@@ -57,51 +59,67 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, Par
while (index < end) {
const line = lines[index]!
const header = line.trim()
if (header.startsWith("*** Add File:")) {
const path = header.slice("*** Add File:".length).trim()
if (!path) {
index++
continue
}
const parsed = parseAdd(lines, index + 1, end)
if (
index === begin + 1 &&
header.startsWith("*** Environment ID:") &&
header.slice("*** Environment ID:".length).trim()
) {
index++
continue
}
if (header.startsWith("*** Add File: ")) {
const path = header.slice("*** Add File: ".length).trim()
const parsed = parseAdd(lines, index + 1, end, path)
if ("error" in parsed) return Result.fail(parsed.error)
hunks.push({ type: "add", path, contents: parsed.content })
index = parsed.next
continue
}
if (header.startsWith("*** Delete File:")) {
const path = header.slice("*** Delete File:".length).trim()
if (!path) {
index++
continue
if (header.startsWith("*** Delete File: ")) {
const path = header.slice("*** Delete File: ".length).trim()
const next = lines[index + 1]?.trim()
if (index + 1 < end && next !== undefined && !isBoundary(next)) {
if (next.startsWith("*** ")) {
return Result.fail(new InvalidHunkError({ line: next, lineNumber: index + 2 }))
}
return Result.fail(
new InvalidHunkError({
line: next,
lineNumber: index + 2,
reason: `Unexpected line after Delete File '${path}': '${next}'. Delete hunks do not contain body lines`,
}),
)
}
hunks.push({ type: "delete", path })
index++
continue
}
if (header.startsWith("*** Update File:")) {
const path = header.slice("*** Update File:".length).trim()
if (!path) {
index++
continue
}
if (header.startsWith("*** Update File: ")) {
const path = header.slice("*** Update File: ".length).trim()
let next = index + 1
let movePath: string | undefined
if (lines[next]?.startsWith("*** Move to:")) {
movePath = lines[next]!.slice("*** Move to:".length).trim()
while (lines[next]?.trimEnd() === "*** End of File") next++
const move = lines[next]?.trimEnd()
if (move === "*** Move to:" || move?.startsWith("*** Move to: ")) {
movePath = move.slice("*** Move to: ".length).trim()
if (!movePath) {
return Result.fail(
new InvalidHunkError({
line: lines[next]!.trim(),
lineNumber: next + 1,
reason: `Move destination for '${path}' must not be empty`,
}),
)
}
next++
}
const parsed = parseUpdate(lines, next, end)
const parsed = parseUpdate(lines, next, end, path, index)
if ("error" in parsed) return Result.fail(parsed.error)
hunks.push({ type: "update", path, movePath, chunks: parsed.chunks })
index = parsed.next
continue
}
index++
}
if (hunks.length === 0) {
const invalid = lines.findIndex((line, index) => index > begin && index < end && line.trim() !== "")
if (invalid !== -1) {
return Result.fail(new InvalidHunkError({ line: lines[invalid]!.trim(), lineNumber: invalid + 1 }))
}
return Result.fail(new InvalidHunkError({ line: header, lineNumber: index + 1 }))
}
return Result.succeed(hunks)
}
@@ -123,47 +141,174 @@ export function joinBom(text: string, bom: boolean) {
return bom ? `\uFEFF${stripped}` : stripped
}
function parseAdd(lines: ReadonlyArray<string>, start: number, end: number) {
function parseAdd(
lines: ReadonlyArray<string>,
start: number,
end: number,
path: string,
): { content: string; next: number } | { error: InvalidHunkError } {
const content: string[] = []
let index = start
while (index < end && !lines[index]!.startsWith("***")) {
if (lines[index]!.startsWith("+")) content.push(lines[index]!.slice(1))
while (index < end && !isBoundary(lines[index]!.trim())) {
if (!lines[index]!.startsWith("+")) {
const line = lines[index]!.trim()
return {
error: new InvalidHunkError({
line,
lineNumber: index + 1,
reason: `Invalid Add File line for '${path}': expected a line starting with '+', got '${line}'`,
}),
}
}
content.push(lines[index]!.slice(1))
index++
}
return { content: content.join("\n"), next: index }
}
function parseUpdate(lines: ReadonlyArray<string>, start: number, end: number) {
const chunks: UpdateFileChunk[] = []
function parseUpdate(
lines: ReadonlyArray<string>,
start: number,
end: number,
path: string,
hunk: number,
): { chunks: ReadonlyArray<UpdateFileChunk>; next: number } | { error: InvalidHunkError } {
const chunks: Array<{
oldLines: string[]
newLines: string[]
changeContext?: string
endOfFile?: boolean
}> = []
let index = start
while (index < end && !lines[index]!.startsWith("***")) {
if (!lines[index]!.startsWith("@@")) {
let afterEndOfFile = false
while (index < end) {
const line = lines[index]!
const updateLine = line.trimEnd()
if (afterEndOfFile) {
if (updateLine === "") {
index++
continue
}
if (updateLine === "@@" || updateLine.startsWith("@@ ")) afterEndOfFile = false
else if (isBoundary(updateLine)) break
else {
return {
error: new InvalidHunkError({
line,
lineNumber: index + 1,
reason: `Expected update hunk to start with a @@ context marker, got: '${line}'`,
}),
}
}
}
if (updateLine === "*** End of File") {
const chunk = chunks.at(-1)
if (chunk && chunk.oldLines.length === 0 && chunk.newLines.length === 0) {
return {
error: new InvalidHunkError({
line: updateLine,
lineNumber: index + 1,
reason: "Update hunk does not contain any lines",
}),
}
}
if (chunk) {
chunk.endOfFile = true
afterEndOfFile = true
}
index++
continue
}
const changeContext = lines[index]!.slice(2).trim() || undefined
const oldLines: string[] = []
const newLines: string[] = []
let endOfFile = false
index++
while (index < end && !lines[index]!.startsWith("@@") && !lines[index]!.startsWith("***")) {
const line = lines[index]!
if (line.startsWith(" ")) {
oldLines.push(line.slice(1))
newLines.push(line.slice(1))
} else if (line.startsWith("-")) oldLines.push(line.slice(1))
else if (line.startsWith("+")) newLines.push(line.slice(1))
if (isBoundary(updateLine)) break
if (updateLine === "@@" || updateLine.startsWith("@@ ")) {
const previous = chunks.at(-1)
if (previous && previous.oldLines.length === 0 && previous.newLines.length === 0) {
return {
error: new InvalidHunkError({
line,
lineNumber: index + 1,
reason: `Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`,
}),
}
}
chunks.push({
oldLines: [],
newLines: [],
changeContext: updateLine === "@@" ? undefined : updateLine.slice("@@ ".length),
})
index++
continue
}
if (lines[index]?.trim() === "*** End of File") {
endOfFile = true
if (chunks.length === 0) chunks.push({ oldLines: [], newLines: [] })
const chunk = chunks.at(-1)!
if (line === "") {
chunk.oldLines.push("")
chunk.newLines.push("")
index++
continue
}
if (line.startsWith(" ")) {
chunk.oldLines.push(line.slice(1))
chunk.newLines.push(line.slice(1))
index++
continue
}
if (line.startsWith("-")) {
chunk.oldLines.push(line.slice(1))
index++
continue
}
if (line.startsWith("+")) {
chunk.newLines.push(line.slice(1))
index++
continue
}
const populated = chunk.oldLines.length > 0 || chunk.newLines.length > 0
return {
error: new InvalidHunkError({
line,
lineNumber: index + 1,
reason: populated
? `Expected update hunk to start with a @@ context marker, got: '${line}'`
: `Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`,
}),
}
}
if (chunks.length === 0) {
return {
error: new InvalidHunkError({
line: lines[hunk]!.trim(),
lineNumber: hunk + 1,
reason: `Update file hunk for path '${path}' is empty`,
}),
}
}
const last = chunks.at(-1)!
if (last.oldLines.length === 0 && last.newLines.length === 0) {
const line = lines[index]!.trim()
return {
error: new InvalidHunkError({
line,
lineNumber: index + 1,
reason:
line === "*** End Patch"
? "Update hunk does not contain any lines"
: `Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`,
}),
}
chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })
}
return { chunks, next: index }
}
function isBoundary(line: string) {
return (
line === "*** End Patch" ||
line.startsWith("*** Add File: ") ||
line.startsWith("*** Delete File: ") ||
line.startsWith("*** Update File: ")
)
}
function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks: ReadonlyArray<UpdateFileChunk>) {
const replacements: Array<readonly [start: number, remove: number, insert: ReadonlyArray<string>]> = []
let lineIndex = 0
@@ -185,6 +330,11 @@ function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks:
if (newLines.at(-1) === "") newLines = newLines.slice(0, -1)
found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
}
if (found === -1 && chunk.oldLines.every((line) => line === "")) {
const expected =
chunk.oldLines.length === 1 ? "an expected blank line" : `${chunk.oldLines.length} consecutive blank lines`
throw new Error(`Failed to find ${expected} in ${path}`)
}
if (found === -1) throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`)
replacements.push([found, oldLines.length, newLines])
lineIndex = found + oldLines.length
@@ -231,5 +381,4 @@ const normalize = (value: string) =>
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
const splitBom = (text: string) =>
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
const stripHeredoc = (input: string) =>
input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input
const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<(['"]?)(\w+)\1\s*\n([\s\S]*?)\n\2\s*$/)?.[3] ?? input