2026-01-02 18:28:29 +00:00
import { describe , expect , test } from "bun:test"
2026-06-03 02:42:13 +00:00
import { SessionV1 } from "@opencode-ai/core/v1/session"
2026-02-09 05:54:01 +00:00
import { APICallError } from "ai"
2026-01-02 18:28:29 +00:00
import { MessageV2 } from "../../src/session/message-v2"
2026-04-27 18:33:33 +00:00
import { ProviderTransform } from "@/provider/transform"
import type { Provider } from "@/provider/provider"
2026-05-31 01:08:38 +00:00
2026-03-11 23:40:50 +00:00
import { SessionID , MessageID , PartID } from "../../src/session/schema"
2026-03-19 00:36:53 +00:00
import { Question } from "../../src/question"
2026-05-31 01:08:38 +00:00
import { ProviderV2 } from "@opencode-ai/core/provider"
2026-06-04 06:57:43 +00:00
import { ModelV2 } from "@opencode-ai/core/model"
2026-01-02 18:28:29 +00:00
2026-03-11 23:16:56 +00:00
const sessionID = SessionID . make ( "session" )
2026-05-31 01:08:38 +00:00
const providerID = ProviderV2 . ID . make ( "test" )
2026-01-20 22:39:00 +00:00
const model : Provider.Model = {
2026-06-04 06:57:43 +00:00
id : ModelV2.ID.make ( "test-model" ) ,
2026-03-12 13:27:52 +00:00
providerID ,
2026-01-20 22:39:00 +00:00
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" ,
}
2026-01-02 18:28:29 +00:00
2026-06-03 02:42:13 +00:00
function userInfo ( id : string ) : SessionV1 . User {
2026-01-02 18:28:29 +00:00
return {
id ,
sessionID ,
role : "user" ,
time : { created : 0 } ,
agent : "user" ,
2026-06-04 06:57:43 +00:00
model : { providerID , modelID : ModelV2.ID.make ( "test" ) } ,
2026-01-02 18:28:29 +00:00
tools : { } ,
mode : "" ,
2026-06-03 02:42:13 +00:00
} as unknown as SessionV1 . User
2026-01-02 18:28:29 +00:00
}
2026-01-20 22:39:00 +00:00
function assistantInfo (
id : string ,
parentID : string ,
2026-06-03 02:42:13 +00:00
error? : SessionV1.Assistant [ "error" ] ,
2026-01-20 22:39:00 +00:00
meta ? : { providerID : string ; modelID : string } ,
2026-06-03 02:42:13 +00:00
) : SessionV1 . Assistant {
2026-01-20 22:39:00 +00:00
const infoModel = meta ? ? { providerID : model.providerID , modelID : model.api.id }
2026-01-02 18:28:29 +00:00
return {
id ,
sessionID ,
role : "assistant" ,
time : { created : 0 } ,
error ,
parentID ,
2026-01-20 22:39:00 +00:00
modelID : infoModel.modelID ,
providerID : infoModel.providerID ,
2026-01-02 18:28:29 +00:00
mode : "" ,
agent : "agent" ,
path : { cwd : "/" , root : "/" } ,
cost : 0 ,
tokens : {
input : 0 ,
output : 0 ,
reasoning : 0 ,
cache : { read : 0 , write : 0 } ,
} ,
2026-06-03 02:42:13 +00:00
} as unknown as SessionV1 . Assistant
2026-01-02 18:28:29 +00:00
}
function basePart ( messageID : string , id : string ) {
return {
2026-05-10 02:58:47 +00:00
id : PartID.make ( id . startsWith ( "prt" ) ? id : ` prt_ ${ id } ` ) ,
2026-01-02 18:28:29 +00:00
sessionID ,
2026-05-10 02:58:47 +00:00
messageID : MessageID.make ( messageID . startsWith ( "msg" ) ? messageID : ` msg_ ${ messageID } ` ) ,
2026-01-02 18:28:29 +00:00
}
}
describe ( "session.message-v2.toModelMessage" , ( ) = > {
2026-03-27 20:24:30 +00:00
test ( "filters out messages with no parts" , async ( ) = > {
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
info : userInfo ( "m-empty" ) ,
parts : [ ] ,
} ,
{
info : userInfo ( "m-user" ) ,
parts : [
{
. . . basePart ( "m-user" , "p1" ) ,
type : "text" ,
text : "hello" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [
2026-01-02 18:28:29 +00:00
{
role : "user" ,
content : [ { type : "text" , text : "hello" } ] ,
} ,
] )
} )
2026-03-27 20:24:30 +00:00
test ( "filters out messages with only ignored parts" , async ( ) = > {
2026-01-02 18:28:29 +00:00
const messageID = "m-user"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
info : userInfo ( messageID ) ,
parts : [
{
. . . basePart ( messageID , "p1" ) ,
type : "text" ,
text : "ignored" ,
ignored : true ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [ ] )
2026-01-02 18:28:29 +00:00
} )
2026-05-08 04:17:43 +00:00
test ( "filters out user messages with only empty text parts" , async ( ) = > {
const messageID = "m-user"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-05-08 04:17:43 +00:00
{
info : userInfo ( messageID ) ,
parts : [
{
. . . basePart ( messageID , "p1" ) ,
type : "text" ,
text : "" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-08 04:17:43 +00:00
} ,
]
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [ ] )
} )
test ( "filters empty user text parts while keeping non-empty parts" , async ( ) = > {
const messageID = "m-user"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-05-08 04:17:43 +00:00
{
info : userInfo ( messageID ) ,
parts : [
{
. . . basePart ( messageID , "p1" ) ,
type : "text" ,
text : "" ,
} ,
{
. . . basePart ( messageID , "p2" ) ,
type : "text" ,
text : "hello" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-08 04:17:43 +00:00
} ,
]
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [
{
role : "user" ,
content : [ { type : "text" , text : "hello" } ] ,
} ,
] )
} )
2026-03-27 20:24:30 +00:00
test ( "includes synthetic text parts" , async ( ) = > {
2026-01-02 18:28:29 +00:00
const messageID = "m-user"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
info : userInfo ( messageID ) ,
parts : [
{
. . . basePart ( messageID , "p1" ) ,
type : "text" ,
text : "hello" ,
synthetic : true ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
{
info : assistantInfo ( "m-assistant" , messageID ) ,
parts : [
{
. . . basePart ( "m-assistant" , "a1" ) ,
type : "text" ,
text : "assistant" ,
synthetic : true ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [
2026-01-02 18:28:29 +00:00
{
role : "user" ,
content : [ { type : "text" , text : "hello" } ] ,
} ,
{
role : "assistant" ,
content : [ { type : "text" , text : "assistant" } ] ,
} ,
] )
} )
2026-03-27 20:24:30 +00:00
test ( "converts user text/file parts and injects compaction/subtask prompts" , async ( ) = > {
2026-01-02 18:28:29 +00:00
const messageID = "m-user"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
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" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [
2026-01-02 18:28:29 +00:00
{
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" } ,
] ,
} ,
] )
} )
2026-03-27 20:24:30 +00:00
test ( "converts assistant tool completion into tool-call + tool-result messages with attachments" , async ( ) = > {
2026-01-02 18:28:29 +00:00
const userID = "m-user"
const assistantID = "m-assistant"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
info : userInfo ( userID ) ,
parts : [
{
. . . basePart ( userID , "u1" ) ,
type : "text" ,
text : "run tool" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
{
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" ,
2026-01-22 05:54:39 +00:00
url : "data:image/png;base64,Zm9v" ,
2026-01-02 18:28:29 +00:00
} ,
] ,
} ,
metadata : { openai : { tool : "meta" } } ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [
2026-01-02 18:28:29 +00:00
{
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" ,
2026-01-22 05:54:39 +00:00
output : {
type : "content" ,
value : [
{ type : "text" , text : "ok" } ,
{ type : "media" , mediaType : "image/png" , data : "Zm9v" } ,
] ,
} ,
2026-01-12 18:33:39 +00:00
providerOptions : { openai : { tool : "meta" } } ,
2026-01-02 18:28:29 +00:00
} ,
] ,
} ,
] )
} )
2026-04-18 15:59:08 +00:00
test ( "preserves jpeg tool-result media for anthropic models" , async ( ) = > {
const anthropicModel : Provider.Model = {
. . . model ,
2026-06-04 06:57:43 +00:00
id : ModelV2.ID.make ( "anthropic/claude-opus-4-7" ) ,
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.make ( "anthropic" ) ,
2026-04-18 15:59:08 +00:00
api : {
id : "claude-opus-4-7-20250805" ,
url : "https://api.anthropic.com" ,
npm : "@ai-sdk/anthropic" ,
} ,
capabilities : {
. . . model . capabilities ,
attachment : true ,
input : {
. . . model . capabilities . input ,
image : true ,
pdf : true ,
} ,
} ,
}
const jpeg = Buffer . from ( [ 0xff , 0xd8 , 0xff , 0xe0 , 0x00 , 0x10 , 0x4a , 0x46 , 0x49 , 0x46 , 0x00 , 0x01 ] ) . toString (
"base64" ,
)
const userID = "m-user-anthropic"
const assistantID = "m-assistant-anthropic"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-04-18 15:59:08 +00:00
{
info : userInfo ( userID ) ,
parts : [
{
. . . basePart ( userID , "u1-anthropic" ) ,
type : "text" ,
text : "run tool" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-04-18 15:59:08 +00:00
} ,
{
info : assistantInfo ( assistantID , userID ) ,
parts : [
{
. . . basePart ( assistantID , "a1-anthropic" ) ,
type : "tool" ,
callID : "call-anthropic-1" ,
tool : "read" ,
state : {
status : "completed" ,
input : { filePath : "/tmp/rails-demo.png" } ,
output : "Image read successfully" ,
title : "Read" ,
metadata : { } ,
time : { start : 0 , end : 1 } ,
attachments : [
{
. . . basePart ( assistantID , "file-anthropic-1" ) ,
type : "file" ,
mime : "image/jpeg" ,
filename : "rails-demo.png" ,
url : ` data:image/jpeg;base64, ${ jpeg } ` ,
} ,
] ,
} ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-04-18 15:59:08 +00:00
} ,
]
const result = ProviderTransform . message ( await MessageV2 . toModelMessages ( input , anthropicModel ) , anthropicModel , { } )
expect ( result ) . toHaveLength ( 3 )
expect ( result [ 2 ] . role ) . toBe ( "tool" )
expect ( result [ 2 ] . content [ 0 ] ) . toMatchObject ( {
type : "tool-result" ,
toolCallId : "call-anthropic-1" ,
toolName : "read" ,
output : {
type : "content" ,
value : [
{ type : "text" , text : "Image read successfully" } ,
{ type : "media" , mediaType : "image/jpeg" , data : jpeg } ,
] ,
} ,
} )
} )
2026-05-08 04:17:43 +00:00
test ( "moves bedrock pdf tool-result media into a separate user message" , async ( ) = > {
const bedrockModel : Provider.Model = {
. . . model ,
2026-06-04 06:57:43 +00:00
id : ModelV2.ID.make ( "amazon-bedrock/anthropic.claude-sonnet-4-6" ) ,
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.make ( "amazon-bedrock" ) ,
2026-05-08 04:17:43 +00:00
api : {
id : "anthropic.claude-sonnet-4-6" ,
url : "https://bedrock-runtime.us-east-1.amazonaws.com" ,
npm : "@ai-sdk/amazon-bedrock" ,
} ,
capabilities : {
. . . model . capabilities ,
attachment : true ,
input : {
. . . model . capabilities . input ,
image : true ,
pdf : true ,
} ,
} ,
}
const pdf = Buffer . from ( "%PDF-1.4\n" ) . toString ( "base64" )
const userID = "m-user-bedrock-pdf"
const assistantID = "m-assistant-bedrock-pdf"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-05-08 04:17:43 +00:00
{
info : userInfo ( userID ) ,
parts : [
{
. . . basePart ( userID , "u1-bedrock-pdf" ) ,
type : "text" ,
text : "run tool" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-08 04:17:43 +00:00
} ,
{
info : assistantInfo ( assistantID , userID ) ,
parts : [
{
. . . basePart ( assistantID , "a1-bedrock-pdf" ) ,
type : "tool" ,
callID : "call-bedrock-pdf-1" ,
tool : "read" ,
state : {
status : "completed" ,
input : { filePath : "/tmp/example.pdf" } ,
output : "PDF read successfully" ,
title : "Read" ,
metadata : { } ,
time : { start : 0 , end : 1 } ,
attachments : [
{
. . . basePart ( assistantID , "file-bedrock-pdf-1" ) ,
type : "file" ,
mime : "application/pdf" ,
filename : "example.pdf" ,
url : ` data:application/pdf;base64, ${ pdf } ` ,
} ,
] ,
} ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-08 04:17:43 +00:00
} ,
]
expect ( await MessageV2 . toModelMessages ( input , bedrockModel ) ) . toStrictEqual ( [
{
role : "user" ,
content : [ { type : "text" , text : "run tool" } ] ,
} ,
{
role : "assistant" ,
content : [
{
type : "tool-call" ,
toolCallId : "call-bedrock-pdf-1" ,
toolName : "read" ,
input : { filePath : "/tmp/example.pdf" } ,
providerExecuted : undefined ,
} ,
] ,
} ,
{
role : "tool" ,
content : [
{
type : "tool-result" ,
toolCallId : "call-bedrock-pdf-1" ,
toolName : "read" ,
output : { type : "text" , value : "PDF read successfully" } ,
} ,
] ,
} ,
{
role : "user" ,
content : [
{ type : "text" , text : "Attached media from tool result:" } ,
2026-05-08 04:18:54 +00:00
{
type : "file" ,
mediaType : "application/pdf" ,
filename : "example.pdf" ,
data : ` data:application/pdf;base64, ${ pdf } ` ,
} ,
2026-05-08 04:17:43 +00:00
] ,
} ,
] )
} )
2026-03-27 20:24:30 +00:00
test ( "omits provider metadata when assistant model differs" , async ( ) = > {
2026-01-20 22:39:00 +00:00
const userID = "m-user"
const assistantID = "m-assistant"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-20 22:39:00 +00:00
{
info : userInfo ( userID ) ,
parts : [
{
. . . basePart ( userID , "u1" ) ,
type : "text" ,
text : "run tool" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-20 22:39:00 +00:00
} ,
{
info : assistantInfo ( assistantID , userID , undefined , { providerID : "other" , modelID : "other" } ) ,
parts : [
{
. . . basePart ( assistantID , "a1" ) ,
type : "text" ,
text : "done" ,
metadata : { openai : { assistant : "meta" } } ,
} ,
{
. . . basePart ( assistantID , "a2" ) ,
2026-05-01 16:15:17 +00:00
type : "reasoning" ,
text : "thinking" ,
metadata : { openai : { reasoning : "meta" } } ,
time : { start : 0 } ,
} ,
{
. . . basePart ( assistantID , "a3" ) ,
2026-01-20 22:39:00 +00:00
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" } } ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-20 22:39:00 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [
2026-01-20 22:39:00 +00:00
{
role : "user" ,
content : [ { type : "text" , text : "run tool" } ] ,
} ,
{
role : "assistant" ,
content : [
{ type : "text" , text : "done" } ,
2026-05-01 16:15:17 +00:00
{ type : "text" , text : "thinking" } ,
2026-01-20 22:39:00 +00:00
{
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" } ,
} ,
] ,
} ,
] )
} )
2026-03-27 20:24:30 +00:00
test ( "replaces compacted tool output with placeholder" , async ( ) = > {
2026-01-02 18:28:29 +00:00
const userID = "m-user"
const assistantID = "m-assistant"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
info : userInfo ( userID ) ,
parts : [
{
. . . basePart ( userID , "u1" ) ,
type : "text" ,
text : "run tool" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
{
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 } ,
} ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [
2026-01-02 18:28:29 +00:00
{
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" ,
2026-01-16 21:49:39 +00:00
output : { type : "text" , value : "[Old tool result content cleared]" } ,
2026-01-02 18:28:29 +00:00
} ,
] ,
} ,
] )
} )
2026-04-22 15:07:32 +00:00
test ( "truncates tool output when requested" , async ( ) = > {
const userID = "m-user"
const assistantID = "m-assistant"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-04-22 15:07:32 +00:00
{
info : userInfo ( userID ) ,
parts : [
{
. . . basePart ( userID , "u1" ) ,
type : "text" ,
text : "run tool" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-04-22 15:07:32 +00:00
} ,
{
info : assistantInfo ( assistantID , userID ) ,
parts : [
{
. . . basePart ( assistantID , "a1" ) ,
type : "tool" ,
callID : "call-1" ,
tool : "bash" ,
state : {
status : "completed" ,
input : { cmd : "ls" } ,
output : "abcdefghij" ,
2026-05-02 23:18:48 +00:00
title : "Shell" ,
2026-04-22 15:07:32 +00:00
metadata : { } ,
time : { start : 0 , end : 1 } ,
} ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-04-22 15:07:32 +00:00
} ,
]
expect ( await MessageV2 . toModelMessages ( input , model , { toolOutputMaxChars : 4 } ) ) . 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 : "abcd\n[Tool output truncated for compaction: omitted 6 chars]" ,
} ,
} ,
] ,
} ,
] )
} )
2026-03-27 20:24:30 +00:00
test ( "converts assistant tool error into error-text tool result" , async ( ) = > {
2026-01-02 18:28:29 +00:00
const userID = "m-user"
const assistantID = "m-assistant"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
info : userInfo ( userID ) ,
parts : [
{
. . . basePart ( userID , "u1" ) ,
type : "text" ,
text : "run tool" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
{
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" } } ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [
2026-01-02 18:28:29 +00:00
{
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" } ,
2026-01-12 18:33:39 +00:00
providerOptions : { openai : { tool : "meta" } } ,
2026-01-02 18:28:29 +00:00
} ,
] ,
} ,
] )
} )
2026-04-09 14:03:26 +00:00
test ( "forwards partial bash output for aborted tool calls" , async ( ) = > {
const userID = "m-user"
const assistantID = "m-assistant"
const output = [
"31403" ,
"12179" ,
"4575" ,
"" ,
2026-05-02 23:18:48 +00:00
"<shell_metadata>" ,
2026-04-09 14:03:26 +00:00
"User aborted the command" ,
2026-05-02 23:18:48 +00:00
"</shell_metadata>" ,
2026-04-09 14:03:26 +00:00
] . join ( "\n" )
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-04-09 14:03:26 +00:00
{
info : userInfo ( userID ) ,
parts : [
{
. . . basePart ( userID , "u1" ) ,
type : "text" ,
text : "run tool" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-04-09 14:03:26 +00:00
} ,
{
info : assistantInfo ( assistantID , userID ) ,
parts : [
{
. . . basePart ( assistantID , "a1" ) ,
type : "tool" ,
callID : "call-1" ,
tool : "bash" ,
state : {
status : "error" ,
input : { command : "for i in {1..20}; do print -- $RANDOM; sleep 1; done" } ,
error : "Tool execution aborted" ,
metadata : { interrupted : true , output } ,
time : { start : 0 , end : 1 } ,
} ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-04-09 14:03:26 +00:00
} ,
]
expect ( await 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 : { command : "for i in {1..20}; do print -- $RANDOM; sleep 1; done" } ,
providerExecuted : undefined ,
} ,
] ,
} ,
{
role : "tool" ,
content : [
{
type : "tool-result" ,
toolCallId : "call-1" ,
toolName : "bash" ,
output : { type : "text" , value : output } ,
} ,
] ,
} ,
] )
} )
2026-03-27 20:24:30 +00:00
test ( "filters assistant messages with non-abort errors" , async ( ) = > {
2026-01-02 18:28:29 +00:00
const assistantID = "m-assistant"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
info : assistantInfo (
assistantID ,
"m-parent" ,
2026-06-03 02:42:13 +00:00
new SessionV1 . APIError ( { message : "boom" , isRetryable : true } ) . toObject ( ) as SessionV1 . APIError ,
2026-01-02 18:28:29 +00:00
) ,
parts : [
{
. . . basePart ( assistantID , "a1" ) ,
type : "text" ,
text : "should not render" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [ ] )
2026-01-02 18:28:29 +00:00
} )
2026-03-27 20:24:30 +00:00
test ( "includes aborted assistant messages only when they have non-step-start/reasoning content" , async ( ) = > {
2026-01-02 18:28:29 +00:00
const assistantID1 = "m-assistant-1"
const assistantID2 = "m-assistant-2"
2026-06-03 02:42:13 +00:00
const aborted = new SessionV1 . AbortedError ( {
2026-05-31 01:09:55 +00:00
message : "aborted" ,
2026-06-03 02:42:13 +00:00
} ) . toObject ( ) as SessionV1 . Assistant [ "error" ]
2026-01-02 18:28:29 +00:00
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
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" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
{
info : assistantInfo ( assistantID2 , "m-parent" , aborted ) ,
parts : [
{
. . . basePart ( assistantID2 , "b1" ) ,
type : "step-start" ,
} ,
{
. . . basePart ( assistantID2 , "b2" ) ,
type : "reasoning" ,
text : "thinking" ,
time : { start : 0 } ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [
2026-01-02 18:28:29 +00:00
{
role : "assistant" ,
content : [
{ type : "reasoning" , text : "thinking" , providerOptions : undefined } ,
{ type : "text" , text : "partial answer" } ,
] ,
} ,
] )
} )
2026-04-26 05:05:16 +00:00
test ( "preserves OpenRouter reasoning details through provider transform" , async ( ) = > {
const assistantID = "m-assistant"
const openrouterModel : Provider.Model = {
. . . model ,
2026-06-04 06:57:43 +00:00
id : ModelV2.ID.make ( "deepseek/deepseek-v4-pro" ) ,
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.make ( "openrouter" ) ,
2026-04-26 05:05:16 +00:00
api : {
id : "deepseek/deepseek-v4-pro" ,
url : "https://openrouter.ai/api/v1" ,
npm : "@openrouter/ai-sdk-provider" ,
} ,
capabilities : {
. . . model . capabilities ,
reasoning : true ,
interleaved : { field : "reasoning_details" } ,
} ,
}
const reasoningDetails = [
{
type : "reasoning.text" ,
text : "thinking" ,
format : "unknown" ,
index : 0 ,
} ,
]
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-04-26 05:05:16 +00:00
{
info : assistantInfo ( assistantID , "m-parent" , undefined , {
providerID : openrouterModel.providerID ,
modelID : openrouterModel.id ,
} ) ,
parts : [
{
. . . basePart ( assistantID , "a1" ) ,
type : "reasoning" ,
text : "thinking" ,
time : { start : 0 } ,
metadata : {
openrouter : {
reasoning_details : reasoningDetails ,
} ,
} ,
} ,
{
. . . basePart ( assistantID , "a2" ) ,
type : "text" ,
text : "answer" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-04-26 05:05:16 +00:00
} ,
]
expect (
ProviderTransform . message ( await MessageV2 . toModelMessages ( input , openrouterModel ) , openrouterModel , { } ) ,
) . toStrictEqual ( [
{
role : "assistant" ,
content : [
{
type : "reasoning" ,
text : "thinking" ,
providerOptions : {
openrouter : {
reasoning_details : reasoningDetails ,
} ,
} ,
} ,
{ type : "text" , text : "answer" } ,
] ,
} ,
] )
} )
2026-03-27 20:24:30 +00:00
test ( "splits assistant messages on step-start boundaries" , async ( ) = > {
2026-01-02 18:28:29 +00:00
const assistantID = "m-assistant"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
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" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [
2026-01-02 18:28:29 +00:00
{
role : "assistant" ,
content : [ { type : "text" , text : "first" } ] ,
} ,
{
role : "assistant" ,
content : [ { type : "text" , text : "second" } ] ,
} ,
] )
} )
2026-03-27 20:24:30 +00:00
test ( "drops messages that only contain step-start parts" , async ( ) = > {
2026-01-02 18:28:29 +00:00
const assistantID = "m-assistant"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-02 18:28:29 +00:00
{
info : assistantInfo ( assistantID , "m-parent" ) ,
parts : [
{
. . . basePart ( assistantID , "p1" ) ,
type : "step-start" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-02 18:28:29 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
expect ( await MessageV2 . toModelMessages ( input , model ) ) . toStrictEqual ( [ ] )
2026-01-02 18:28:29 +00:00
} )
2026-01-15 22:00:36 +00:00
2026-03-27 20:24:30 +00:00
test ( "converts pending/running tool calls to error results to prevent dangling tool_use" , async ( ) = > {
2026-01-15 22:00:36 +00:00
const userID = "m-user"
const assistantID = "m-assistant"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-01-15 22:00:36 +00:00
{
info : userInfo ( userID ) ,
parts : [
{
. . . basePart ( userID , "u1" ) ,
type : "text" ,
text : "run tool" ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-15 22:00:36 +00:00
} ,
{
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 } ,
} ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-01-15 22:00:36 +00:00
} ,
]
2026-03-27 20:24:30 +00:00
const result = await MessageV2 . toModelMessages ( input , model )
2026-01-15 22:00:36 +00:00
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]" } ,
} ,
] ,
} ,
] )
} )
2026-05-06 23:57:56 +00:00
test ( "substitutes space for empty text between signed reasoning blocks" , async ( ) = > {
// Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)]
const assistantID = "m-assistant"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-05-06 23:57:56 +00:00
{
info : assistantInfo ( assistantID , "m-parent" ) ,
parts : [
{ . . . basePart ( assistantID , "p1" ) , type : "step-start" } ,
{
. . . basePart ( assistantID , "p2" ) ,
type : "reasoning" ,
text : "thinking-one" ,
metadata : { anthropic : { signature : "sig1" } } ,
} ,
{ . . . basePart ( assistantID , "p3" ) , type : "text" , text : "" } ,
{ . . . basePart ( assistantID , "p4" ) , type : "step-start" } ,
{
. . . basePart ( assistantID , "p5" ) ,
type : "reasoning" ,
text : "thinking-two" ,
metadata : { anthropic : { signature : "sig2" } } ,
} ,
{ . . . basePart ( assistantID , "p6" ) , type : "text" , text : "the answer" } ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-06 23:57:56 +00:00
} ,
]
const result = await MessageV2 . toModelMessages ( input , model )
// step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later
expect ( result ) . toHaveLength ( 2 )
expect ( ( result [ 0 ] . content as any [ ] ) . find ( ( p ) = > p . type === "text" ) . text ) . toBe ( " " )
expect ( ( result [ 1 ] . content as any [ ] ) . find ( ( p ) = > p . type === "text" ) . text ) . toBe ( "the answer" )
} )
2026-05-08 04:17:43 +00:00
test ( "leaves empty text alone when reasoning signature is under 'bedrock' namespace" , async ( ) = > {
// Bedrock signed reasoning is preserved as reasoning metadata, but unlike the
// direct Anthropic path we do not preserve empty text separators for Bedrock.
2026-05-06 23:57:56 +00:00
const assistantID = "m-assistant-bedrock"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-05-06 23:57:56 +00:00
{
info : assistantInfo ( assistantID , "m-parent" ) ,
parts : [
{
. . . basePart ( assistantID , "p1" ) ,
type : "reasoning" ,
text : "thinking-bedrock" ,
metadata : { bedrock : { signature : "bedrock-sig" } } ,
} ,
{ . . . basePart ( assistantID , "p2" ) , type : "text" , text : "" } ,
{ . . . basePart ( assistantID , "p3" ) , type : "text" , text : "answer" } ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-06 23:57:56 +00:00
} ,
]
const result = await MessageV2 . toModelMessages ( input , model )
expect ( result ) . toHaveLength ( 1 )
const texts = ( result [ 0 ] . content as any [ ] ) . filter ( ( p ) = > p . type === "text" )
2026-05-08 04:17:43 +00:00
expect ( texts . map ( ( t ) = > t . text ) ) . toStrictEqual ( [ "" , "answer" ] )
2026-05-06 23:57:56 +00:00
} )
test ( "leaves empty text alone when reasoning has no Anthropic signature" , async ( ) = > {
// Non-Anthropic providers' reasoning doesn't position-validate, so empty text
// should be filtered normally rather than substituted.
const assistantID = "m-assistant-unsigned"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-05-06 23:57:56 +00:00
{
info : assistantInfo ( assistantID , "m-parent" ) ,
parts : [
{ . . . basePart ( assistantID , "p1" ) , type : "reasoning" , text : "thinking" } ,
{ . . . basePart ( assistantID , "p2" ) , type : "text" , text : "" } ,
{ . . . basePart ( assistantID , "p3" ) , type : "text" , text : "answer" } ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-06 23:57:56 +00:00
} ,
]
const result = await MessageV2 . toModelMessages ( input , model )
expect ( result ) . toHaveLength ( 1 )
const texts = ( result [ 0 ] . content as any [ ] ) . filter ( ( p ) = > p . type === "text" )
expect ( texts . map ( ( t ) = > t . text ) ) . toStrictEqual ( [ "" , "answer" ] )
} )
test ( "leaves empty text alone in assistant messages without reasoning" , async ( ) = > {
const assistantID = "m-assistant-no-reasoning"
2026-06-03 02:42:13 +00:00
const input : SessionV1.WithParts [ ] = [
2026-05-06 23:57:56 +00:00
{
info : assistantInfo ( assistantID , "m-parent" ) ,
parts : [
{ . . . basePart ( assistantID , "p1" ) , type : "text" , text : "" } ,
{ . . . basePart ( assistantID , "p2" ) , type : "text" , text : "hello" } ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-06 23:57:56 +00:00
} ,
]
const result = await MessageV2 . toModelMessages ( input , model )
expect ( result ) . toHaveLength ( 1 )
const texts = ( result [ 0 ] . content as any [ ] ) . filter ( ( p ) = > p . type === "text" )
expect ( texts . map ( ( t ) = > t . text ) ) . toStrictEqual ( [ "" , "hello" ] )
} )
2026-01-02 18:28:29 +00:00
} )
2026-02-09 02:55:41 +00:00
describe ( "session.message-v2.fromError" , ( ) = > {
2026-02-09 05:54:01 +00:00
test ( "serializes context_length_exceeded as ContextOverflowError" , ( ) = > {
const input = {
type : "error" ,
error : {
2026-02-09 02:55:41 +00:00
code : "context_length_exceeded" ,
2026-02-09 05:54:01 +00:00
} ,
}
2026-03-12 13:27:52 +00:00
const result = MessageV2 . fromError ( input , { providerID } )
2026-02-09 05:54:01 +00:00
expect ( result ) . toStrictEqual ( {
name : "ContextOverflowError" ,
data : {
2026-02-09 02:55:41 +00:00
message : "Input exceeds context window of this model" ,
2026-02-09 05:54:01 +00:00
responseBody : JSON.stringify ( input ) ,
2026-02-09 02:55:41 +00:00
} ,
2026-02-09 05:54:01 +00:00
} )
} )
test ( "serializes response error codes" , ( ) = > {
const cases = [
2026-02-09 02:55:41 +00:00
{
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 ,
} ,
}
2026-03-12 13:27:52 +00:00
const result = MessageV2 . fromError ( input , { providerID } )
2026-02-09 02:55:41 +00:00
expect ( result ) . toStrictEqual ( {
name : "APIError" ,
data : {
message : item.message ,
isRetryable : false ,
responseBody : JSON.stringify ( input ) ,
} ,
} )
} )
} )
2026-04-23 21:31:43 +00:00
test ( "serializes OpenAI response server_error stream chunks as retryable APIError" , ( ) = > {
const body = {
type : "error" ,
sequence_number : 2 ,
error : {
type : "server_error" ,
code : "server_error" ,
message :
"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_77eccd008d984bf6bf82d1b2c2b68715 in your message." ,
param : null ,
} ,
}
const result = MessageV2 . fromError ( { message : JSON.stringify ( body ) } , { providerID } )
expect ( result ) . toStrictEqual ( {
name : "APIError" ,
data : {
message : body.error.message ,
isRetryable : true ,
responseBody : JSON.stringify ( body ) ,
} ,
} )
} )
2026-02-09 05:54:01 +00:00
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)" ,
2026-07-07 14:46:27 +00:00
"tokens in request more than max tokens allowed" ,
2026-02-09 05:54:01 +00:00
"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 ,
} )
2026-03-12 13:27:52 +00:00
const result = MessageV2 . fromError ( error , { providerID } )
2026-06-03 02:42:13 +00:00
expect ( SessionV1 . ContextOverflowError . isInstance ( result ) ) . toBe ( true )
2026-02-09 05:54:01 +00:00
} )
} )
2026-03-16 15:25:24 +00:00
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 } )
2026-06-03 02:42:13 +00:00
expect ( SessionV1 . ContextOverflowError . isInstance ( result ) ) . toBe ( true )
2026-03-16 15:25:24 +00:00
} )
2026-02-09 05:54:01 +00:00
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 ,
} ) ,
2026-03-12 13:27:52 +00:00
{ providerID } ,
2026-02-09 05:54:01 +00:00
)
2026-06-03 02:42:13 +00:00
expect ( SessionV1 . ContextOverflowError . isInstance ( result ) ) . toBe ( false )
expect ( SessionV1 . APIError . isInstance ( result ) ) . toBe ( true )
2026-02-09 05:54:01 +00:00
} )
2026-02-09 02:55:41 +00:00
test ( "serializes unknown inputs" , ( ) = > {
2026-03-12 13:27:52 +00:00
const result = MessageV2 . fromError ( 123 , { providerID } )
2026-02-09 02:55:41 +00:00
expect ( result ) . toStrictEqual ( {
name : "UnknownError" ,
data : {
message : "123" ,
} ,
} )
} )
2026-03-19 00:36:53 +00:00
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" ,
} ,
} )
} )
2026-03-25 18:08:40 +00:00
test ( "classifies ZlibError from fetch as retryable APIError" , ( ) = > {
const zlibError = new Error (
'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()' ,
)
; ( zlibError as any ) . code = "ZlibError"
; ( zlibError as any ) . errno = 0
; ( zlibError as any ) . path = ""
const result = MessageV2 . fromError ( zlibError , { providerID } )
2026-06-03 02:42:13 +00:00
expect ( SessionV1 . APIError . isInstance ( result ) ) . toBe ( true )
expect ( ( result as SessionV1 . APIError ) . data . isRetryable ) . toBe ( true )
expect ( ( result as SessionV1 . APIError ) . data . message ) . toInclude ( "decompression" )
2026-03-25 18:08:40 +00:00
} )
test ( "classifies ZlibError as AbortedError when abort context is provided" , ( ) = > {
const zlibError = new Error (
'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()' ,
)
; ( zlibError as any ) . code = "ZlibError"
; ( zlibError as any ) . errno = 0
const result = MessageV2 . fromError ( zlibError , { providerID , aborted : true } )
expect ( result . name ) . toBe ( "MessageAbortedError" )
} )
2026-02-09 02:55:41 +00:00
} )
2026-05-14 17:56:12 +00:00
describe ( "session.message-v2.latest" , ( ) = > {
const TAIL_USER = MessageID . make ( "msg_001" )
const OVERFLOW_ASSISTANT = MessageID . make ( "msg_002" )
const COMPACTION_USER = MessageID . make ( "msg_003" )
const SUMMARY_ASSISTANT = MessageID . make ( "msg_004" )
const CONTINUE_USER = MessageID . make ( "msg_005" )
const NEW_COMPACTION_USER = MessageID . make ( "msg_006" )
2026-06-03 02:42:13 +00:00
const tailUser : SessionV1.WithParts = {
2026-05-14 17:56:12 +00:00
info : userInfo ( TAIL_USER ) ,
2026-06-03 02:42:13 +00:00
parts : [ { . . . basePart ( TAIL_USER , "p1" ) , type : "text" , text : "original prompt" } ] as SessionV1 . Part [ ] ,
2026-05-14 17:56:12 +00:00
}
2026-06-03 02:42:13 +00:00
const overflowAssistant : SessionV1.WithParts = {
2026-05-14 17:56:12 +00:00
info : {
. . . assistantInfo ( OVERFLOW_ASSISTANT , TAIL_USER ) ,
finish : "tool-calls" ,
tokens : { input : 280_000 , output : 200 , reasoning : 0 , cache : { read : 0 , write : 0 } , total : 280_200 } ,
2026-06-03 02:42:13 +00:00
} as SessionV1 . Assistant ,
2026-05-14 17:56:12 +00:00
parts : [ ] ,
}
2026-06-03 02:42:13 +00:00
const compactionUser : SessionV1.WithParts = {
2026-05-14 17:56:12 +00:00
info : userInfo ( COMPACTION_USER ) ,
parts : [
{
. . . basePart ( COMPACTION_USER , "p1" ) ,
type : "compaction" ,
auto : true ,
tail_start_id : TAIL_USER ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-14 17:56:12 +00:00
}
2026-06-03 02:42:13 +00:00
const summaryAssistant : SessionV1.WithParts = {
2026-05-14 17:56:12 +00:00
info : {
. . . assistantInfo ( SUMMARY_ASSISTANT , COMPACTION_USER ) ,
summary : true ,
finish : "stop" ,
tokens : { input : 150_000 , output : 1_500 , reasoning : 0 , cache : { read : 0 , write : 0 } , total : 151_500 } ,
2026-06-03 02:42:13 +00:00
} as SessionV1 . Assistant ,
2026-05-14 17:56:12 +00:00
parts : [ ] ,
}
2026-06-03 02:42:13 +00:00
const continueUser : SessionV1.WithParts = {
2026-05-14 17:56:12 +00:00
info : userInfo ( CONTINUE_USER ) ,
parts : [
{
. . . basePart ( CONTINUE_USER , "p1" ) ,
type : "text" ,
text : "Continue if you have next steps..." ,
synthetic : true ,
metadata : { compaction_continue : true } ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-14 17:56:12 +00:00
}
// Regression for double auto-compaction. The reorder in filterCompacted
// (#27145) returns [compaction-user, summary, ...tail..., continue-user],
// so picking lastFinished by array position landed on the pre-compaction
// overflow assistant and bypassed the `summary !== true` overflow guard
// in SessionPrompt.runLoop, firing a second compaction.create immediately.
test ( "finished is the chronologically-latest finished assistant, not the array-latest" , ( ) = > {
const filtered = MessageV2 . filterCompacted ( [
continueUser ,
summaryAssistant ,
compactionUser ,
overflowAssistant ,
tailUser ,
] )
const state = MessageV2 . latest ( filtered )
expect ( state . finished ? . id ) . toBe ( SUMMARY_ASSISTANT )
expect ( state . finished ? . summary ) . toBe ( true )
expect ( state . user ? . id ) . toBe ( CONTINUE_USER )
expect ( state . tasks ) . toEqual ( [ ] )
} )
test ( "a fresh compaction-user newer than the latest summary surfaces in tasks" , ( ) = > {
2026-06-03 02:42:13 +00:00
const newCompactionUser : SessionV1.WithParts = {
2026-05-14 17:56:12 +00:00
info : userInfo ( NEW_COMPACTION_USER ) ,
parts : [
{
. . . basePart ( NEW_COMPACTION_USER , "p1" ) ,
type : "compaction" ,
auto : true ,
} ,
2026-06-03 02:42:13 +00:00
] as SessionV1 . Part [ ] ,
2026-05-14 17:56:12 +00:00
}
const state = MessageV2 . latest ( [
tailUser ,
overflowAssistant ,
compactionUser ,
summaryAssistant ,
continueUser ,
newCompactionUser ,
] )
expect ( state . finished ? . id ) . toBe ( SUMMARY_ASSISTANT )
expect ( state . user ? . id ) . toBe ( NEW_COMPACTION_USER )
expect ( state . tasks ) . toHaveLength ( 1 )
expect ( state . tasks [ 0 ] ) . toMatchObject ( { type : "compaction" , auto : true } )
} )
} )