Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4abfeb4964 | |||
| 4de6a5f60a | |||
| 2271f9b222 | |||
| 8a36abd328 | |||
| 532292b5f3 | |||
| 89e3141079 | |||
| 7a1f9764a2 | |||
| b91dd78ab3 | |||
| dba5da7c10 | |||
| aea36d7630 | |||
| 03474816ea | |||
| 5a9ed4d350 | |||
| 4204b9d087 | |||
| 8fad13365b | |||
| 5841b04fe7 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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`)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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"`)
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,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}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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={() => {}}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user