mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-09 02:09:12 +00:00
refactor: apply minimal tfcode branding
- Rename packages/opencode → packages/tfcode (directory only) - Rename bin/opencode → bin/tfcode (CLI binary) - Rename .opencode → .tfcode (config directory) - Update package.json name and bin field - Update config directory path references (.tfcode) - Keep internal code references as 'opencode' for easy upstream sync - Keep @opencode-ai/* workspace package names This minimal branding approach allows clean merges from upstream opencode repository while providing tfcode branding for users.
This commit is contained in:
930
packages/tfcode/test/session/message-v2.test.ts
Normal file
930
packages/tfcode/test/session/message-v2.test.ts
Normal file
@@ -0,0 +1,930 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { APICallError } from "ai"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { SessionID, MessageID, PartID } from "../../src/session/schema"
|
||||
import { Question } from "../../src/question"
|
||||
|
||||
const sessionID = SessionID.make("session")
|
||||
const providerID = ProviderID.make("test")
|
||||
const model: Provider.Model = {
|
||||
id: ModelID.make("test-model"),
|
||||
providerID,
|
||||
api: {
|
||||
id: "test-model",
|
||||
url: "https://example.com",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
name: "Test Model",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
output: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
context: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
}
|
||||
|
||||
function userInfo(id: string): MessageV2.User {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 0 },
|
||||
agent: "user",
|
||||
model: { providerID, modelID: ModelID.make("test") },
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as MessageV2.User
|
||||
}
|
||||
|
||||
function assistantInfo(
|
||||
id: string,
|
||||
parentID: string,
|
||||
error?: MessageV2.Assistant["error"],
|
||||
meta?: { providerID: string; modelID: string },
|
||||
): MessageV2.Assistant {
|
||||
const infoModel = meta ?? { providerID: model.providerID, modelID: model.api.id }
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 0 },
|
||||
error,
|
||||
parentID,
|
||||
modelID: infoModel.modelID,
|
||||
providerID: infoModel.providerID,
|
||||
mode: "",
|
||||
agent: "agent",
|
||||
path: { cwd: "/", root: "/" },
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
} as unknown as MessageV2.Assistant
|
||||
}
|
||||
|
||||
function basePart(messageID: string, id: string) {
|
||||
return {
|
||||
id: PartID.make(id),
|
||||
sessionID,
|
||||
messageID: MessageID.make(messageID),
|
||||
}
|
||||
}
|
||||
|
||||
describe("session.message-v2.toModelMessage", () => {
|
||||
test("filters out messages with no parts", () => {
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo("m-empty"),
|
||||
parts: [],
|
||||
},
|
||||
{
|
||||
info: userInfo("m-user"),
|
||||
parts: [
|
||||
{
|
||||
...basePart("m-user", "p1"),
|
||||
type: "text",
|
||||
text: "hello",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("filters out messages with only ignored parts", () => {
|
||||
const messageID = "m-user"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(messageID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(messageID, "p1"),
|
||||
type: "text",
|
||||
text: "ignored",
|
||||
ignored: true,
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
|
||||
})
|
||||
|
||||
test("includes synthetic text parts", () => {
|
||||
const messageID = "m-user"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(messageID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(messageID, "p1"),
|
||||
type: "text",
|
||||
text: "hello",
|
||||
synthetic: true,
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
{
|
||||
info: assistantInfo("m-assistant", messageID),
|
||||
parts: [
|
||||
{
|
||||
...basePart("m-assistant", "a1"),
|
||||
type: "text",
|
||||
text: "assistant",
|
||||
synthetic: true,
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("converts user text/file parts and injects compaction/subtask prompts", () => {
|
||||
const messageID = "m-user"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(messageID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(messageID, "p1"),
|
||||
type: "text",
|
||||
text: "hello",
|
||||
},
|
||||
{
|
||||
...basePart(messageID, "p2"),
|
||||
type: "text",
|
||||
text: "ignored",
|
||||
ignored: true,
|
||||
},
|
||||
{
|
||||
...basePart(messageID, "p3"),
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "img.png",
|
||||
url: "https://example.com/img.png",
|
||||
},
|
||||
{
|
||||
...basePart(messageID, "p4"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "note.txt",
|
||||
url: "https://example.com/note.txt",
|
||||
},
|
||||
{
|
||||
...basePart(messageID, "p5"),
|
||||
type: "file",
|
||||
mime: "application/x-directory",
|
||||
filename: "dir",
|
||||
url: "https://example.com/dir",
|
||||
},
|
||||
{
|
||||
...basePart(messageID, "p6"),
|
||||
type: "compaction",
|
||||
auto: true,
|
||||
},
|
||||
{
|
||||
...basePart(messageID, "p7"),
|
||||
type: "subtask",
|
||||
prompt: "prompt",
|
||||
description: "desc",
|
||||
agent: "agent",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{
|
||||
type: "file",
|
||||
mediaType: "image/png",
|
||||
filename: "img.png",
|
||||
data: "https://example.com/img.png",
|
||||
},
|
||||
{ type: "text", text: "What did we do so far?" },
|
||||
{ type: "text", text: "The following tool was executed by the user" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("converts assistant tool completion into tool-call + tool-result messages with attachments", () => {
|
||||
const userID = "m-user"
|
||||
const assistantID = "m-assistant"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(userID, "u1"),
|
||||
type: "text",
|
||||
text: "run tool",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
{
|
||||
info: assistantInfo(assistantID, userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID, "a1"),
|
||||
type: "text",
|
||||
text: "done",
|
||||
metadata: { openai: { assistant: "meta" } },
|
||||
},
|
||||
{
|
||||
...basePart(assistantID, "a2"),
|
||||
type: "tool",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { cmd: "ls" },
|
||||
output: "ok",
|
||||
title: "Bash",
|
||||
metadata: {},
|
||||
time: { start: 0, end: 1 },
|
||||
attachments: [
|
||||
{
|
||||
...basePart(assistantID, "file-1"),
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "attachment.png",
|
||||
url: "data:image/png;base64,Zm9v",
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: { openai: { tool: "meta" } },
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "run tool" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "done", providerOptions: { openai: { assistant: "meta" } } },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "bash",
|
||||
input: { cmd: "ls" },
|
||||
providerExecuted: undefined,
|
||||
providerOptions: { openai: { tool: "meta" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
toolName: "bash",
|
||||
output: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "ok" },
|
||||
{ type: "media", mediaType: "image/png", data: "Zm9v" },
|
||||
],
|
||||
},
|
||||
providerOptions: { openai: { tool: "meta" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("omits provider metadata when assistant model differs", () => {
|
||||
const userID = "m-user"
|
||||
const assistantID = "m-assistant"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(userID, "u1"),
|
||||
type: "text",
|
||||
text: "run tool",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
{
|
||||
info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID, "a1"),
|
||||
type: "text",
|
||||
text: "done",
|
||||
metadata: { openai: { assistant: "meta" } },
|
||||
},
|
||||
{
|
||||
...basePart(assistantID, "a2"),
|
||||
type: "tool",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { cmd: "ls" },
|
||||
output: "ok",
|
||||
title: "Bash",
|
||||
metadata: {},
|
||||
time: { start: 0, end: 1 },
|
||||
},
|
||||
metadata: { openai: { tool: "meta" } },
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "run tool" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "bash",
|
||||
input: { cmd: "ls" },
|
||||
providerExecuted: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
toolName: "bash",
|
||||
output: { type: "text", value: "ok" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("replaces compacted tool output with placeholder", () => {
|
||||
const userID = "m-user"
|
||||
const assistantID = "m-assistant"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(userID, "u1"),
|
||||
type: "text",
|
||||
text: "run tool",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
{
|
||||
info: assistantInfo(assistantID, userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID, "a1"),
|
||||
type: "tool",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { cmd: "ls" },
|
||||
output: "this should be cleared",
|
||||
title: "Bash",
|
||||
metadata: {},
|
||||
time: { start: 0, end: 1, compacted: 1 },
|
||||
},
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "run tool" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "bash",
|
||||
input: { cmd: "ls" },
|
||||
providerExecuted: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
toolName: "bash",
|
||||
output: { type: "text", value: "[Old tool result content cleared]" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("converts assistant tool error into error-text tool result", () => {
|
||||
const userID = "m-user"
|
||||
const assistantID = "m-assistant"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(userID, "u1"),
|
||||
type: "text",
|
||||
text: "run tool",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
{
|
||||
info: assistantInfo(assistantID, userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID, "a1"),
|
||||
type: "tool",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "error",
|
||||
input: { cmd: "ls" },
|
||||
error: "nope",
|
||||
time: { start: 0, end: 1 },
|
||||
metadata: {},
|
||||
},
|
||||
metadata: { openai: { tool: "meta" } },
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "run tool" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "bash",
|
||||
input: { cmd: "ls" },
|
||||
providerExecuted: undefined,
|
||||
providerOptions: { openai: { tool: "meta" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
toolName: "bash",
|
||||
output: { type: "error-text", value: "nope" },
|
||||
providerOptions: { openai: { tool: "meta" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("filters assistant messages with non-abort errors", () => {
|
||||
const assistantID = "m-assistant"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: assistantInfo(
|
||||
assistantID,
|
||||
"m-parent",
|
||||
new MessageV2.APIError({ message: "boom", isRetryable: true }).toObject() as MessageV2.APIError,
|
||||
),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID, "a1"),
|
||||
type: "text",
|
||||
text: "should not render",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
|
||||
})
|
||||
|
||||
test("includes aborted assistant messages only when they have non-step-start/reasoning content", () => {
|
||||
const assistantID1 = "m-assistant-1"
|
||||
const assistantID2 = "m-assistant-2"
|
||||
|
||||
const aborted = new MessageV2.AbortedError({ message: "aborted" }).toObject() as MessageV2.Assistant["error"]
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: assistantInfo(assistantID1, "m-parent", aborted),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID1, "a1"),
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
time: { start: 0 },
|
||||
},
|
||||
{
|
||||
...basePart(assistantID1, "a2"),
|
||||
type: "text",
|
||||
text: "partial answer",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
{
|
||||
info: assistantInfo(assistantID2, "m-parent", aborted),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID2, "b1"),
|
||||
type: "step-start",
|
||||
},
|
||||
{
|
||||
...basePart(assistantID2, "b2"),
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
time: { start: 0 },
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "thinking", providerOptions: undefined },
|
||||
{ type: "text", text: "partial answer" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("splits assistant messages on step-start boundaries", () => {
|
||||
const assistantID = "m-assistant"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: assistantInfo(assistantID, "m-parent"),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID, "p1"),
|
||||
type: "text",
|
||||
text: "first",
|
||||
},
|
||||
{
|
||||
...basePart(assistantID, "p2"),
|
||||
type: "step-start",
|
||||
},
|
||||
{
|
||||
...basePart(assistantID, "p3"),
|
||||
type: "text",
|
||||
text: "second",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "first" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "second" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("drops messages that only contain step-start parts", () => {
|
||||
const assistantID = "m-assistant"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: assistantInfo(assistantID, "m-parent"),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID, "p1"),
|
||||
type: "step-start",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
|
||||
})
|
||||
|
||||
test("converts pending/running tool calls to error results to prevent dangling tool_use", () => {
|
||||
const userID = "m-user"
|
||||
const assistantID = "m-assistant"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(userID, "u1"),
|
||||
type: "text",
|
||||
text: "run tool",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
{
|
||||
info: assistantInfo(assistantID, userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID, "a1"),
|
||||
type: "tool",
|
||||
callID: "call-pending",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "pending",
|
||||
input: { cmd: "ls" },
|
||||
raw: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
...basePart(assistantID, "a2"),
|
||||
type: "tool",
|
||||
callID: "call-running",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { path: "/tmp" },
|
||||
time: { start: 0 },
|
||||
},
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
const result = MessageV2.toModelMessages(input, model)
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "run tool" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-pending",
|
||||
toolName: "bash",
|
||||
input: { cmd: "ls" },
|
||||
providerExecuted: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-running",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp" },
|
||||
providerExecuted: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-pending",
|
||||
toolName: "bash",
|
||||
output: { type: "error-text", value: "[Tool execution was interrupted]" },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-running",
|
||||
toolName: "read",
|
||||
output: { type: "error-text", value: "[Tool execution was interrupted]" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.message-v2.fromError", () => {
|
||||
test("serializes context_length_exceeded as ContextOverflowError", () => {
|
||||
const input = {
|
||||
type: "error",
|
||||
error: {
|
||||
code: "context_length_exceeded",
|
||||
},
|
||||
}
|
||||
const result = MessageV2.fromError(input, { providerID })
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
name: "ContextOverflowError",
|
||||
data: {
|
||||
message: "Input exceeds context window of this model",
|
||||
responseBody: JSON.stringify(input),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes response error codes", () => {
|
||||
const cases = [
|
||||
{
|
||||
code: "insufficient_quota",
|
||||
message: "Quota exceeded. Check your plan and billing details.",
|
||||
},
|
||||
{
|
||||
code: "usage_not_included",
|
||||
message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.",
|
||||
},
|
||||
{
|
||||
code: "invalid_prompt",
|
||||
message: "Invalid prompt from test",
|
||||
},
|
||||
]
|
||||
|
||||
cases.forEach((item) => {
|
||||
const input = {
|
||||
type: "error",
|
||||
error: {
|
||||
code: item.code,
|
||||
message: item.code === "invalid_prompt" ? item.message : undefined,
|
||||
},
|
||||
}
|
||||
const result = MessageV2.fromError(input, { providerID })
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
name: "APIError",
|
||||
data: {
|
||||
message: item.message,
|
||||
isRetryable: false,
|
||||
responseBody: JSON.stringify(input),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("detects context overflow from APICallError provider messages", () => {
|
||||
const cases = [
|
||||
"prompt is too long: 213462 tokens > 200000 maximum",
|
||||
"Your input exceeds the context window of this model",
|
||||
"The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)",
|
||||
"Please reduce the length of the messages or completion",
|
||||
"400 status code (no body)",
|
||||
"413 status code (no body)",
|
||||
]
|
||||
|
||||
cases.forEach((message) => {
|
||||
const error = new APICallError({
|
||||
message,
|
||||
url: "https://example.com",
|
||||
requestBodyValues: {},
|
||||
statusCode: 400,
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
isRetryable: false,
|
||||
})
|
||||
const result = MessageV2.fromError(error, { providerID })
|
||||
expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("detects context overflow from context_length_exceeded code in response body", () => {
|
||||
const error = new APICallError({
|
||||
message: "Request failed",
|
||||
url: "https://example.com",
|
||||
requestBodyValues: {},
|
||||
statusCode: 422,
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
responseBody: JSON.stringify({
|
||||
error: {
|
||||
message: "Some message",
|
||||
type: "invalid_request_error",
|
||||
code: "context_length_exceeded",
|
||||
},
|
||||
}),
|
||||
isRetryable: false,
|
||||
})
|
||||
const result = MessageV2.fromError(error, { providerID })
|
||||
expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not classify 429 no body as context overflow", () => {
|
||||
const result = MessageV2.fromError(
|
||||
new APICallError({
|
||||
message: "429 status code (no body)",
|
||||
url: "https://example.com",
|
||||
requestBodyValues: {},
|
||||
statusCode: 429,
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
isRetryable: false,
|
||||
}),
|
||||
{ providerID },
|
||||
)
|
||||
expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(false)
|
||||
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
||||
})
|
||||
|
||||
test("serializes unknown inputs", () => {
|
||||
const result = MessageV2.fromError(123, { providerID })
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: "123",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes tagged errors with their message", () => {
|
||||
const result = MessageV2.fromError(new Question.RejectedError(), { providerID })
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: "The user dismissed this question",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user