2026-09-26 01:52:47 +03:00
/**
* SESSION MODEL tests (components/session-core.ts — Wave D): the pure state
* machine that replaced useChat + the AI-SDK transport. Everything the old
* transport/merge suites pinned survives here on the native wire:
*
* - the 409 IDENTITY-RETRY (postInputEvent): the event was never persisted
* pre-acceptance, so the retry waits out the racing turn and re-POSTs
* the SAME body — no merge, no record read;
* - the reattach path (resumeStream): record refetch, then the SAME
* verbatim-AgentEvent consume as a POST; turn-settled is the terminal;
* byte-split SSE frames never corrupt (the framing pin);
* - append-only ADOPTION (adoptRecord/absorbed): server-added steers show
* mid-turn, the record rewrite bails to the idle full sync, and the
* optimistic entries the write-ahead absorbed never double their
* bubbles;
* - the view application (applyWireEvent → projectView): full-partial
* replaces, the fork-2 question-placeholder ignore, execution states,
* raw error display (owner ruling);
* - the continuation fold (needsContinuation): the mount/sync seam's
* native replacement for lastAssistantMessageIsCompleteWithToolCalls.
*/
import assert from "node:assert/strict" ;
import { afterEach , describe , it } from "node:test" ;
import { absorbed , pendingCallTool , SessionModel } from "../components/session-core.ts" ;
import type { ChatInput } from "../src/core/input-events.ts" ;
import type { LhMessage } from "../src/core/native-messages.ts" ;
2026-10-05 01:02:43 +03:00
import { newUserMessage , newAnswerResult , newThinkingMessage , needsContinuation , endsInDismissal } from "../src/core/native-messages.ts" ;
2026-10-24 23:55:27 +03:00
import { newCompactionMarker , newPlanBlockMessage , projectView } from "../src/core/index.ts" ;
2026-10-03 01:51:13 +03:00
import { dismissedAnswer } from "../src/core/question-core.ts" ;
2026-10-02 04:04:04 +03:00
import { buildAnswer , type QuestionData } from "../src/core/question-core.ts" ;
2026-09-26 01:52:47 +03:00
const realFetch = globalThis . fetch ;
afterEach (() => {
globalThis . fetch = realFetch ;
});
/** SSE-frame each object the way the server's subscriber stream does. */
function sse (... events : unknown []) : string {
return events . map (( e ) => ( e === "[DONE]" ? "data: [DONE]\n\n" : `data: ${ JSON . stringify ( e ) } \ n \ n` )). join ( "" );
}
/** A Response whose body drips `text` in `slice`-byte reads (the framing pin). */
function dripped ( text : string , slice : number , status = 200 ) : Response {
const bytes = new TextEncoder (). encode ( text );
const body = new ReadableStream < Uint8Array >({
start ( controller ) {
for ( let i = 0 ; i < bytes . length ; i += slice ) {
controller . enqueue ( bytes . slice ( i , i + slice ));
}
controller . close ();
},
});
return new Response ( body , { status });
}
/** A plain single-read 200 body. */
function sseResponse (... events : unknown []) : Response {
return new Response ( sse (... events ), { status : 200 });
}
2026-10-10 09:42:47 +03:00
/** CONNECTION DIET B mock (owner, 2026-10-10 — parallel-session freeze
* postmortem): send() no longer consumes the POST response — it acks,
* closes, and reattaches. The wire sequence a send drives: POST /api/chat
* (tiny JSON ack) → GET /api/chats/chat_x (record refetch — the
* write-ahead absorbs the optimistic bubble) → GET .../stream (the SSE
* wire). `userText` seeds the record's write-ahead user message; `post`
* overrides the ack (409/error-path tests keep their own scripts);
* `onPost` observes the POST body. */
function dietFetch ( opts : {
stream : () => Response ;
userText? : string ;
post ?: () => Response ;
onPost ?: ( body : string ) => void ;
}) : typeof globalThis . fetch {
return ( async ( input : RequestInfo | URL , init? : RequestInit ) => {
const url = String ( input );
if ( url === "/api/chat" ) {
if ( opts . onPost && typeof init ? . body === "string" ) opts . onPost ( init . body );
return opts . post ? .() ?? Response . json ({ ok : true });
}
if ( url === "/api/chats/chat_x" ) {
const messages =
opts . userText === undefined ? [] : [{ id : "u1" , role : "user" , content : opts.userText , timestamp : 1 }];
return Response . json ({ messages });
}
if ( url === "/api/chats/chat_x/stream" ) return opts . stream ();
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
}
2026-09-26 01:52:47 +03:00
function assistantMsg ( id : string , text : string ) : LhMessage {
return {
id ,
role : "assistant" ,
content : [{ type : "text" , text }],
stopReason : "stop" ,
usage : { input : 0 , output : 0 , total : 0 , cacheReads : 0 , cacheWrites : 0 },
timestamp : 0 ,
provider : "openrouter" ,
model : "test" ,
} as unknown as LhMessage ;
}
2026-09-26 02:25:04 +03:00
/** A streaming assistant partial (pi events carry the FULL partial — and
* NO message.id: the id exists only once the server's persistTurn stamps
* the record; the client tracks the in-flight message by slot). */
function partial ( blocks : unknown [], stopReason? : string ) : Record < string , unknown > {
2026-09-26 01:52:47 +03:00
return {
type : "message_update" ,
message : {
role : "assistant" ,
content : blocks ,
...( stopReason ? { stopReason } : {}),
usage : { input : 0 , output : 0 , total : 0 , cacheReads : 0 , cacheWrites : 0 },
provider : "openrouter" ,
model : "test" ,
},
assistantMessageEvent : { type : "text" , textDelta : "" },
};
}
function model ( opts : Partial < ConstructorParameters < typeof SessionModel > [ 0 ] > = {}) : SessionModel {
return new SessionModel ({ chatId : "chat_x" , initialMessages : [], ... opts });
}
// ─── the 409 identity-retry ────────────────────────────────────────────────
interface Call {
url : string ;
body : string | undefined ;
}
function installFetch ( routes : {
chatPOST : ( call : Call , attempt : number ) => Response ;
jobsGET ?: ( poll : number ) => boolean ;
}) : Call [] {
const calls : Call [] = [];
let poll = 0 ;
globalThis . fetch = ( async ( input : RequestInfo | URL , init? : RequestInit ) => {
const url = String ( input );
calls . push ({ url , body : init?.body as string | undefined });
if ( url === "/api/chat" && init ? . method === "POST" ) {
return routes . chatPOST ( calls . at ( - 1 ) ! , calls . filter (( c ) => c . url === "/api/chat" ). length );
}
if ( url . startsWith ( "/api/jobs" )) return Response . json ({ turnInFlight : routes.jobsGET?. ( ++ poll ) ?? false });
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
return calls ;
}
describe ( "postInputEvent (the 409 identity-retry)" , { skip : process.env.NO_SLOW ? true : undefined }, () => {
2026-09-26 02:13:18 +03:00
it ( "the model's default fetch is RECEIVER-SAFE (the browser 'Illegal invocation' pin)" , async () => {
// The live empty-session bug (2026-09-26): storing globalThis.fetch
// UNBOUND and calling it as this.fetch(...) throws "Illegal
// invocation" in the browser (native fetch requires this === Window;
// Node's undici doesn't care, so every test passed while the app was
// dead). Pin the browser's receiver discipline with a strict stub.
let receiverWasGlobal = false ;
globalThis . fetch = ( function ( this : unknown , ... args : Parameters < typeof fetch >) {
if ( this !== globalThis ) throw new TypeError ( "Failed to execute 'fetch' on 'Window': Illegal invocation" );
receiverWasGlobal = true ;
return Promise . resolve ( sseResponse ( "[DONE]" ));
}) as typeof fetch ;
const m = model (); // NO injected fetch — exercises the default wrapper
const res = await m . postInputEvent ({ type : "text" , text : "hi" });
assert . equal ( res . status , 200 );
assert . ok ( receiverWasGlobal , "the default wrapper calls fetch with the global receiver" );
});
2026-09-26 01:52:47 +03:00
it ( "passes non-409 responses through untouched" , async () => {
const calls = installFetch ({ chatPOST : () => new Response ( "stream" , { status : 200 }) });
const m = model ({ pollMs : 10 , waitMaxMs : 100 });
const res = await m . postInputEvent ({ type : "text" , text : "hi" });
assert . equal ( res . status , 200 );
assert . equal ( calls . length , 1 , "no polling, single call" );
assert . deepEqual ( JSON . parse ( calls [ 0 ] ! . body ! ), { chatId : "chat_x" , input : { type : "text" , text : "hi" } });
});
it ( "waits out the turn, then re-POSTs the SAME event body (identity retry)" , async () => {
const event : ChatInput = { type : "text" , text : "my question while the wake turn ran" };
const calls = installFetch ({
chatPOST : ( _c , n ) => ( n === 1 ? new Response ( "busy" , { status : 409 }) : new Response ( "stream" , { status : 200 })),
jobsGET : ( poll ) => poll <= 1 , // first poll: still running; second: done
});
const m = model ({ pollMs : 10 , waitMaxMs : 100 });
const res = await m . postInputEvent ( event );
assert . equal ( res . status , 200 );
const posts = calls . filter (( c ) => c . url === "/api/chat" );
assert . equal ( posts . length , 2 , "one 409, one retry" );
assert . equal ( posts [ 1 ] ! . body , posts [ 0 ] ! . body , "the event body is byte-identical" );
});
it ( "never reads the record — the event is the only payload" , async () => {
const calls = installFetch ({
chatPOST : ( _c , n ) => ( n === 1 ? new Response ( "busy" , { status : 409 }) : new Response ( "stream" , { status : 200 })),
});
const m = model ({ pollMs : 10 , waitMaxMs : 100 });
assert . equal (( await m . postInputEvent ({ type : "text" , text : "q" })). status , 200 );
assert . equal (
calls . filter (( c ) => c . url . startsWith ( "/api/chats/" )). length ,
0 ,
"the retry never reads the record — the server owns it" ,
);
});
it ( "loops on repeated 409s, re-POSTing the same event each time" , async () => {
const event : ChatInput = { type : "answer" , toolCallId : "call-1" , answer : { correct : true } as never };
const calls = installFetch ({
chatPOST : ( _c , n ) => ( n <= 2 ? new Response ( "busy" , { status : 409 }) : new Response ( "stream" , { status : 200 })),
});
const m = model ({ pollMs : 10 , waitMaxMs : 100 });
assert . equal (( await m . postInputEvent ( event )). status , 200 );
const posts = calls . filter (( c ) => c . url === "/api/chat" );
assert . equal ( posts . length , 3 );
assert . ok ( posts . every (( c ) => c . body === posts [ 0 ] ! . body ), "every attempt carries the same event" );
});
it ( "gives up after the deadline and surfaces the last 409" , async () => {
const calls = installFetch ({
chatPOST : () => new Response ( "busy" , { status : 409 }),
jobsGET : () => true ,
});
const m = model ({ pollMs : 10 , waitMaxMs : 100 });
const res = await m . postInputEvent ({ type : "text" , text : "q" });
assert . equal ( res . status , 409 );
const polls = calls . filter (( c ) => c . url . startsWith ( "/api/jobs" )). length ;
assert . ok ( polls <= 15 , `expected bounded polling (~10× 10ms), got ${ polls } ` );
});
});
// ─── send: optimistic appends + wire consumption ────────────────────────────
describe ( "send (one input event, verbatim consumption)" , () => {
it ( "optimistically appends the text, POSTs {chatId, input}, consumes to [DONE]" , async () => {
const posts : string [] = [];
2026-10-10 09:42:47 +03:00
globalThis . fetch = dietFetch ({
onPost : ( b ) => posts . push ( b ),
userText : "my topic" , // the write-ahead the record refetch absorbs
stream : () =>
sseResponse (
2026-09-26 01:52:47 +03:00
{ type : "agent_start" },
2026-09-26 02:25:04 +03:00
{ type : "message_start" , message : { role : "assistant" , content : [] } },
partial ([{ type : "text" , text : "hello there" }]),
{ type : "message_end" , message : { role : "assistant" , content : [{ type : "text" , text : "hello there" }], stopReason : "stop" } },
2026-09-26 01:52:47 +03:00
{ type : "agent_end" , messages : [] },
"[DONE]" ,
2026-10-10 09:42:47 +03:00
),
});
2026-09-26 01:52:47 +03:00
const m = model ();
const pending = m . send ({ type : "text" , text : "my topic" });
// The optimistic bubble is INSTANT (the instant-answer UX seam):
assert . equal ( m . getState (). status , "submitted" );
assert . match (( m . getState (). nativeMessages . at ( - 1 ) as { id? : string }). id ?? "" , /^c_/ );
assert . equal ( m . getState (). messages . at ( - 1 ) ! . parts [ 0 ] ! . text , "my topic" );
await pending ;
assert . deepEqual ( JSON . parse ( posts [ 0 ] ! ), { chatId : "chat_x" , input : { type : "text" , text : "my topic" } });
assert . equal ( m . getState (). status , "ready" );
// The streamed assistant REPLACED through full partials (never deltas):
const view = m . getState (). messages ;
assert . equal ( view . at ( - 1 ) ! . role , "assistant" );
assert . equal ( view . at ( - 1 ) ! . parts . at ( - 1 ) ! . text , "hello there" );
});
2026-09-26 02:25:04 +03:00
it ( "N id-less updates replace the SAME tail slot (the per-chunk divider bug pin)" , async () => {
// pi's wire (captured live, 2026-09-26): message_start/update/end carry
// AssistantMessages with NO id. The bug: minting a fresh id per event
// made every message_update APPEND a full copy — the tail grew one
// message per chunk, and projectView's step separators + thinking
// blocks made the transcript fill with dividers mid-stream (cleaned up
// only by the settle sync). The fix: the in-flight message is tracked
// by slot; updates REPLACE it.
let push : ( e : unknown ) => void = () => {};
const enc = new TextEncoder ();
const body = new ReadableStream < Uint8Array >({
start ( controller ) {
push = ( e ) => controller . enqueue ( enc . encode ( e === "[DONE]" ? "data: [DONE]\n\n" : `data: ${ JSON . stringify ( e ) } \ n \ n` ));
},
});
2026-10-10 09:42:47 +03:00
globalThis . fetch = dietFetch ({ userText : "go" , stream : () => new Response ( body , { status : 200 }) });
2026-09-26 02:25:04 +03:00
const m = model ();
const done = m . send ({ type : "text" , text : "go" });
const settle = () => new Promise (( r ) => setTimeout ( r , 0 ));
push ({ type : "message_start" , message : { role : "assistant" , content : [{ type : "thinking" , thinking : "hmm" }] } });
for ( const text of [ "Hel" , "Hello" , "Hello wor" , "Hello world" ]) {
push ( partial ([{ type : "thinking" , thinking : "hmm" }, { type : "text" , text }]));
await settle ();
}
// MID-STREAM: exactly ONE assistant message in the tail — never one
// per chunk — carrying the LATEST partial (full snapshot, not deltas).
const assistants = m . getState (). nativeMessages . filter (( x ) => ( x as { role? : string }). role === "assistant" );
assert . equal ( assistants . length , 1 , "one streaming message, replaced in place" );
assert . equal ( m . getState (). messages . at ( - 1 ) ! . parts . filter (( p ) => p . type === "text" ). length , 1 );
assert . equal ( m . getState (). messages . at ( - 1 ) ! . parts . at ( - 1 ) ! . text , "Hello world" );
// Stable id across updates → stable view-part keys (no per-chunk
// remount of the whole transcript, mermaid included).
const idMid = ( assistants [ 0 ] as { id? : string }). id ;
assert . match ( idMid ?? "" , /^c_/ , "client-minted slot id" );
push ({ type : "message_end" , message : { role : "assistant" , content : [{ type : "thinking" , thinking : "hmm" }, { type : "text" , text : "Hello world" }], stopReason : "stop" } });
push ({ type : "agent_end" , messages : [] });
push ( "[DONE]" );
await done ;
const final = m . getState (). nativeMessages . filter (( x ) => ( x as { role? : string }). role === "assistant" );
assert . equal ( final . length , 1 );
assert . equal (( final [ 0 ] as { id? : string }). id , idMid , "same slot through message_end" );
});
2026-09-26 01:52:47 +03:00
it ( "a 503 refusal surfaces the RAW body text (parseModelsRefusal's contract)" , async () => {
globalThis . fetch = ( async () =>
new Response ( JSON . stringify ({ code : "models_not_configured" , missing : [ "strong" ] }), {
status : 503 ,
})) as typeof globalThis . fetch ;
const m = model ();
await m . send ({ type : "text" , text : "q" });
const state = m . getState ();
assert . equal ( state . status , "error" );
assert . equal (
state . error ! . message ,
JSON . stringify ({ code : "models_not_configured" , missing : [ "strong" ] }),
"the banner carries the raw envelope — the refusal flow JSON-parses it" ,
);
});
it ( "the optimistic answer result attaches to the pending quiz part at once" , async () => {
const quizCall = {
id : "call-1" ,
role : "assistant" ,
content : [
{ type : "text" , text : "Q?" },
{ type : "toolCall" , id : "call-1" , name : "quiz" , arguments : { question : "Q?" , choices : [ "a" , "b" ] } },
],
stopReason : "toolUse" ,
usage : { input : 0 , output : 0 , total : 0 , cacheReads : 0 , cacheWrites : 0 },
provider : "openrouter" ,
model : "test" ,
timestamp : 0 ,
} as unknown as LhMessage ;
let postBody = "" ;
globalThis . fetch = ( async ( input : RequestInfo | URL , init? : RequestInit ) => {
if ( String ( input ) === "/api/chat" ) {
postBody = init ? . body as string ;
return sseResponse ( "[DONE]" );
}
throw new Error ( `unexpected fetch: ${ String ( input ) } ` );
}) as typeof globalThis . fetch ;
const m = model ({ initialMessages : [ newUserMessage ( "hi" ), quizCall ] });
const pending = m . submitAnswer ( "quiz" , "call-1" , { correct : true } as never );
// The dock's AnsweredCard state — before any server round-trip:
const state = m . getState ();
const quizPart = state . messages
. flatMap (( v ) => v . parts )
. find (( p ) => p . type === "tool-quiz" ) ! ;
assert . equal ( quizPart . state , "output-available" );
assert . deepEqual ( quizPart . output , { correct : true });
await pending ;
assert . deepEqual ( JSON . parse ( postBody ), {
chatId : "chat_x" ,
input : { type : "answer" , toolCallId : "call-1" , answer : { correct : true } },
});
});
it ( "ignores the question placeholder's clean execution end (fork 2) and keeps errors" , async () => {
// The dock's mount gate keys on the part STATE the live execution
// layer drives MID-STREAM: a clean question execution stays
// input-available (the in-memory "Question posed" placeholder
// never shows); an INVALID call is output-error and NEVER mounts
// (the modal-realism crash pin).
const enc = new TextEncoder ();
let push : ( e : unknown ) => void = () => {};
const body = new ReadableStream < Uint8Array >({
start ( controller ) {
push = ( e ) => controller . enqueue ( enc . encode ( e === "[DONE]" ? "data: [DONE]\n\n" : `data: ${ JSON . stringify ( e ) } \ n \ n` ));
},
});
2026-10-10 09:42:47 +03:00
globalThis . fetch = dietFetch ({ userText : "q" , stream : () => new Response ( body , { status : 200 }) });
2026-09-26 01:52:47 +03:00
const m = model ();
const done = m . send ({ type : "text" , text : "q" });
const settle = () => new Promise (( r ) => setTimeout ( r , 0 ));
push ({
type : "message_start" ,
message : {
role : "assistant" ,
content : [
{ type : "toolCall" , id : "q1" , name : "quiz" , arguments : {} },
{ type : "toolCall" , id : "bad1" , name : "quiz" , arguments : {} },
],
},
});
await settle ();
push ({ type : "tool_execution_end" , toolCallId : "q1" , toolName : "quiz" , isError : false , result : { content : [{ type : "text" , text : "Question posed" }], details : { placeholder : true } } });
push ({ type : "tool_execution_end" , toolCallId : "bad1" , toolName : "quiz" , isError : true , result : { content : [{ type : "text" , text : "correctAnswer does not match any option value" }], details : undefined } });
await settle ();
const parts = m . getState (). messages . flatMap (( v ) => v . parts ). filter (( p ) => p . type === "tool-quiz" );
assert . equal ( parts . length , 2 );
assert . equal ( parts [ 0 ] ! . state , "input-available" , "the clean placeholder is IGNORED — the dock mounts on it" );
assert . equal ( parts [ 1 ] ! . state , "output-error" );
assert . equal ( parts [ 1 ] ! . errorText , "correctAnswer does not match any option value" );
2026-09-26 02:13:18 +03:00
// turn_end carries the placeholder as a toolResult MESSAGE (details:
// null — tool-execute questionResult): it must not flip the part to
// output-available either — the AnsweredCard would read .dismissed off
// null and crash the whole render (the live crash, 2026-09-26).
push ({
type : "turn_end" ,
2026-09-26 02:25:04 +03:00
message : { role : "assistant" , content : [{ type : "text" , text : "" }], stopReason : "toolUse" },
2026-09-26 02:13:18 +03:00
toolResults : [
{
id : "tr_q1" ,
role : "toolResult" ,
toolCallId : "q1" ,
toolName : "quiz" ,
content : [{ type : "text" , text : "Question posed — the turn ends here; the learner's graded answer arrives as this call's result." }],
details : null ,
isError : false ,
} as never ,
],
});
await settle ();
const afterTurnEnd = m . getState (). messages . flatMap (( v ) => v . parts ). filter (( p ) => p . type === "tool-quiz" );
assert . equal ( afterTurnEnd [ 0 ] ! . state , "input-available" , "the placeholder toolResult keeps the dock's mount state" );
assert . equal ( afterTurnEnd [ 0 ] ! . output , undefined , "no null output ever reaches the AnsweredCard" );
2026-09-26 01:52:47 +03:00
push ({ type : "agent_end" , messages : [] });
push ( "[DONE]" );
await done ;
});
2026-09-26 03:46:09 +03:00
it ( "Wave C: markers injected mid-run (custom role, follow-up delivery) append to the tail by id" , () => {
// A never-settling fetch: the send only needs to flip the model into its
// streaming status synchronously — its POST floats (never rejects) while
// the injected wire events are asserted.
const m = model ({ fetch : () => new Promise < Response >(() => {}) });
m . adoptRecord ([
{ id : "u1" , role : "user" , content : "hello" , timestamp : 1 } as never ,
] as never );
void m . send ({ type : "text" , text : "hello" });
// pi emits message_start + message_end for an injected follow-up marker
// (no streaming partials) — both must UPSERT the same tail slot by id.
const marker = {
id : "steer_job_x_1" ,
role : "custom" ,
customType : "subagent-steer" ,
content : "The research result arrived." ,
display : true ,
details : { jobId : "job_x" , kind : "research" },
timestamp : 2 ,
};
m . applyWireEvent ({ type : "message_start" , message : marker } as never );
m . applyWireEvent ({ type : "message_end" , message : marker } as never );
const state = m . getState ();
const ids = state . messages . map (( v ) => v . parts . length );
assert . ok ( state . messages . length >= 2 , "the marker produced a view message" );
const tail = state . messages . at ( - 1 ) ! ;
assert . equal ( tail . role , "user" , "a steer marker projects as a user-role view message" );
assert . ok (
tail . parts . some (( p ) => p . type === "data-subagent-artifact" ),
"the marker renders its artifact part (SubagentSteerCard)" ,
);
// A re-delivery of the same id (message_end replay) must NOT duplicate.
m . applyWireEvent ({ type : "message_end" , message : marker } as never );
const again = m . getState ();
assert . equal ( again . messages . length , state . messages . length , "same id upserts, never appends a second copy" );
});
2026-09-26 01:52:47 +03:00
it ( "a provider error rides VERBATIM (owner ruling) — no masking anywhere" , async () => {
2026-10-10 09:42:47 +03:00
globalThis . fetch = dietFetch ({
userText : "q" ,
stream : () =>
sseResponse (
partial ([{ type : "text" , text : "" }]),
{ type : "message_end" , message : { role : "assistant" , content : [{ type : "text" , text : "" }], stopReason : "error" , errorMessage : "OpenRouter 429: rate limited" } },
{ type : "agent_end" , messages : [] },
"[DONE]" ,
),
});
2026-09-26 01:52:47 +03:00
const m = model ();
await m . send ({ type : "text" , text : "q" });
assert . equal ( m . getState (). status , "ready" );
assert . equal ( m . getState (). error ! . message , "OpenRouter 429: rate limited" );
});
});
2026-10-24 23:55:27 +03:00
// ─── provider failures: one final-entry rule for live and saved records ──────
describe ( "provider-error banner (live and saved)" , () => {
function failed ( id = "failed" , errorMessage : string | undefined = "Google 500: fixture failure" ) : LhMessage {
return { ... assistantMsg ( id , "" ), content : [], stopReason : "error" , errorMessage } as LhMessage ;
}
it ( "restores an empty failed response on load and full sync without adding a transcript card" , () => {
const record = [ newUserMessage ( "topic" ), failed ()];
const m = model ({ initialMessages : record });
assert . equal ( m . getState (). error ? . message , "Google 500: fixture failure" );
assert . equal ( m . getState (). status , "ready" );
assert . equal ( m . readyRef , true , "a restored provider failure does not block retry" );
assert . deepEqual ( m . getState (). nativeMessages , record , "diagnostic history stays intact" );
assert . equal ( m . getState (). messages . length , 1 , "only the user bubble, no permanent error card" );
const synced = model ();
synced . setMessages ( record );
assert . equal ( synced . getState (). error ? . message , m . getState (). error ? . message );
const adopted = model ({ initialMessages : record.slice ( 0 , 1 ) });
adopted . adoptRecord ( record );
assert . equal ( adopted . getState (). error ? . message , m . getState (). error ? . message );
});
it ( "uses the existing fallback wording when the saved failure has no error text" , () => {
const withoutText = { ... failed (), errorMessage : undefined } as LhMessage ;
assert . equal ( model ({ initialMessages : [ withoutText ] }). getState (). error ? . message , "An error occurred." );
assert . equal ( model (). getState (). error , null );
});
it ( "any later entry supersedes the failure, including plan/compaction markers and aborts" , () => {
const failure = failed ();
const later : LhMessage [] = [
newUserMessage ( "retry" ),
assistantMsg ( "success" , "Hello" ),
{ ... assistantMsg ( "aborted" , "" ), stopReason : "aborted" , errorMessage : "Aborted" } as LhMessage ,
newThinkingMessage ( "problem" , "my attempt" ),
resultMsg ( "call" ),
newPlanBlockMessage ( "plan" , { kind : "full" , nodes : [], edges : [], current : null }),
newCompactionMarker ( "summary" , { firstKeptMessageId : failure.id , tokensBefore : 100 , model : "fixture" , at : "2026-01-01T00:00:00Z" }),
];
for ( const entry of later ) {
const m = model ({ initialMessages : [ failure ] });
m . applyWireEvent ({ type : "message_end" , message : failure });
assert . ok ( m . getState (). error , "the live failure is initially visible" );
m . setMessages ([ failure , entry ]);
assert . equal ( m . getState (). error , null , `live banner superseded by ${ entry . role } ` );
assert . equal ( model ({ initialMessages : [ failure , entry ] }). getState (). error , null , "reopened view agrees" );
}
});
it ( "a newer live message supersedes the failure before its response finishes" , () => {
const m = model ();
m . applyWireEvent ({ type : "message_end" , message : failed () });
assert . ok ( m . getState (). error );
m . applyWireEvent ({ type : "message_start" , message : assistantMsg ( "next" , "" ) });
assert . equal ( m . getState (). error , null );
m . applyWireEvent ({ type : "message_end" , message : assistantMsg ( "next" , "Hello" ) });
assert . equal ( m . getState (). error , null );
});
it ( "does not treat an unfinished assistant's error-shaped partial as a completed failure" , () => {
const m = model ();
const failure = failed ();
m . applyWireEvent ({ type : "message_start" , message : failure });
assert . equal ( m . getState (). error , null );
m . applyWireEvent ({ type : "message_update" , message : failure });
assert . equal ( m . getState (). error , null );
m . applyWireEvent ({ type : "message_end" , message : failure });
assert . equal ( m . getState (). error ? . message , "Google 500: fixture failure" );
});
for ( const input of [{ type : "text" , text : "retry" }, { type : "continue" }] as const ) {
it ( `hides the old failure during the optimistic ${ input . type } wait, then shows a new live failure` , async () => {
let acknowledge !: ( response : Response ) => void ;
const ack = new Promise < Response >(( resolve ) => { acknowledge = resolve ; });
const old = failed ( "old" , "old failure" );
const fresh = failed ( "fresh" , "new failure" );
let finish !: () => void ;
const stream = new ReadableStream < Uint8Array >({ start ( controller ) {
finish = () => {
controller . enqueue ( new TextEncoder (). encode ( sse ( "[DONE]" )));
controller . close ();
};
} });
const m = model ({ initialMessages : [ old ], fetch : async ( url ) => {
if ( url === "/api/chat" ) return ack ;
if ( url === "/api/chats/chat_x" ) return Response . json ({ messages : [ old ] });
if ( url === "/api/chats/chat_x/stream" ) return new Response ( stream );
throw new Error ( `unexpected fetch: ${ url } ` );
} });
assert . equal ( m . getState (). error ? . message , "old failure" );
const sending = m . send ( input );
assert . equal ( m . getState (). status , "submitted" );
assert . equal ( m . getState (). error , null , "hidden even before the acknowledgement" );
acknowledge ( Response . json ({ ok : true }));
await new Promise (( resolve ) => setTimeout ( resolve , 0 ));
assert . equal ( m . getState (). error , null , "refetching the old failed record does not resurrect it" );
m . applyWireEvent ({ type : "message_start" , message : fresh });
assert . equal ( m . getState (). error , null , "no error from an unfinished partial" );
m . applyWireEvent ({ type : "message_end" , message : fresh });
assert . equal ( m . getState (). status , "submitted" );
assert . equal ( m . getState (). error ? . message , "new failure" , "new failure appears before stream closure" );
finish ();
await sending ;
assert . equal ( m . getState (). error ? . message , "new failure" );
});
}
it ( "hides the old failure immediately on fresh-mount reattach, and restores final-entry truth at idle" , async () => {
let settle !: ( response : Response ) => void ;
const response = new Promise < Response >(( resolve ) => { settle = resolve ; });
const m = model ({ initialMessages : [ failed ()], fetch : async () => response });
const attaching = m . resumeStream ( false );
assert . equal ( m . getState (). status , "submitted" );
assert . equal ( m . getState (). error , null );
settle ( sseResponse ({ type : "turn-settled" }, "[DONE]" ));
await attaching ;
assert . equal ( m . getState (). error ? . message , "Google 500: fixture failure" , "no newer entry superseded it" );
});
it ( "recovers a failure that settled before the submitter could attach to its stream" , async () => {
const record = [ newUserMessage ( "topic" ), failed ()];
const m = model ({ fetch : async ( url ) => {
if ( url === "/api/chat" ) return Response . json ({ ok : true });
if ( url === "/api/chats/chat_x" ) return Response . json ({ messages : record });
if ( url === "/api/chats/chat_x/stream" ) return sseResponse ({ type : "turn-settled" }, "[DONE]" );
throw new Error ( `unexpected fetch: ${ url } ` );
} });
m . setReattachSettled (() => m . setMessages ( record ));
await m . send ({ type : "text" , text : "topic" });
assert . equal ( m . getState (). status , "ready" );
assert . equal ( m . getState (). error ? . message , "Google 500: fixture failure" );
});
2026-10-25 00:00:29 +03:00
it ( "a newer live provider failure replaces an earlier attachment error immediately" , async () => {
let attempts = 0 ;
const seen : Array < { status : string ; error : string | undefined } > = [];
const m = model ({ fetch : async () => {
if ( ++ attempts === 1 ) return new Response ( "earlier attach failure" , { status : 500 });
return sseResponse (
{ type : "message_start" , message : failed ( "new" , "new provider failure" ) },
{ type : "message_end" , message : failed ( "new" , "new provider failure" ) },
"[DONE]" ,
);
} });
await m . resumeStream ( false );
assert . equal ( m . getState (). error ? . message , "earlier attach failure" );
m . onChange = () => { seen . push ({ status : m.status , error : m.getState (). error ? . message }); };
await m . resumeStream ( false );
assert . ok ( seen . some (( s ) => s . status === "streaming" && s . error === "new provider failure" ), "new failure wins before stream closure" );
assert . equal ( m . getState (). error ? . message , "new provider failure" );
assert . equal ( model ({ initialMessages : m.getState (). nativeMessages }). getState (). error ? . message , "new provider failure" , "live and reopened views agree" );
});
it ( "an aborted older consume cannot restore the old banner during a replacement reattach" , async () => {
let attempts = 0 ;
let firstRead !: () => void ;
const reading = new Promise < void >(( resolve ) => { firstRead = resolve ; });
let settleReplacement !: ( response : Response ) => void ;
const replacement = new Promise < Response >(( resolve ) => { settleReplacement = resolve ; });
const m = model ({ initialMessages : [ failed ()], fetch : async ( _url , init ) => {
if ( ++ attempts > 1 ) return replacement ;
return new Response ( new ReadableStream < Uint8Array >({
start ( controller ) {
controller . enqueue ( new TextEncoder (). encode ( sse ({ type : "agent_start" })));
init ? . signal ? . addEventListener ( "abort" , () => controller . error ( new DOMException ( "Aborted" , "AbortError" )), { once : true });
},
pull() { firstRead (); },
}));
} });
const first = m . resumeStream ( false );
await reading ;
const second = m . resumeStream ( false );
await first ;
assert . equal ( m . getState (). status , "submitted" , "old cleanup must not settle the replacement attempt" );
assert . equal ( m . getState (). error , null , "old saved failure stays hidden during the replacement's Thinking wait" );
settleReplacement ( sseResponse (
{ type : "message_start" , message : failed ( "new" , "replacement failure" ) },
{ type : "message_end" , message : failed ( "new" , "replacement failure" ) },
"[DONE]" ,
));
await second ;
assert . equal ( m . getState (). error ? . message , "replacement failure" );
});
2026-10-24 23:55:27 +03:00
it ( "keeps loop/request errors transient, with the existing precedence and clearError behavior" , async () => {
const m = model ({ initialMessages : [ failed ()] });
m . applyWireEvent ({ type : "error" , errorText : "loop failure" });
assert . equal ( m . getState (). error ? . message , "loop failure" );
assert . equal ( model ({ initialMessages : m.getState (). nativeMessages }). getState (). error ? . message , "Google 500: fixture failure" );
m . clearError ();
assert . equal ( m . getState (). error ? . message , "Google 500: fixture failure" );
m . fetch = async () => new Response ( "request refused" , { status : 500 });
await m . send ({ type : "text" , text : "retry" });
assert . equal ( m . getState (). error ? . message , "request refused" );
assert . equal ( model ({ initialMessages : m.getState (). nativeMessages }). getState (). error , null , "request errors are not fabricated as saved messages" );
m . clearError ();
assert . equal ( m . getState (). error , null );
});
});
2026-09-26 01:52:47 +03:00
// ─── reattach (resumeStream) ────────────────────────────────────────────────
describe ( "resumeStream (the reattach path)" , () => {
it ( "refetches the record, then GETs the stream endpoint" , async () => {
const urls : string [] = [];
globalThis . fetch = ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
urls . push ( url );
if ( url === "/api/chats/chat_x" ) return Response . json ({ messages : [] });
if ( url === "/api/chats/chat_x/stream" )
2026-09-26 02:25:04 +03:00
return dripped ( sse ({ type : "message_start" , message : { role : "assistant" , content : [{ type : "text" , text : "hi" }] } }, "[DONE]" ), 8 );
2026-09-26 01:52:47 +03:00
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
const m = model ();
await m . resumeStream ();
assert . deepEqual ( urls , [ "/api/chats/chat_x" , "/api/chats/chat_x/stream" ]);
assert . equal ( m . getState (). status , "ready" );
});
2026-10-02 02:23:20 +03:00
it ( "ATTACH-FIRST (owner, reattach round 2026-10-02): a fresh-mount reattach skips the blocking record refetch" , async () => {
const urls : string [] = [];
globalThis . fetch = ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
urls . push ( url );
if ( url === "/api/chats/chat_x/stream" )
return sseResponse (
{ type : "message_start" , message : { role : "assistant" , content : [{ type : "text" , text : "hi" }] } },
{ type : "message_end" , message : { role : "assistant" , content : [{ type : "text" , text : "hi" }], stopReason : "stop" } },
{ type : "agent_end" , messages : [] },
"[DONE]" ,
);
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
const m = model ();
await m . resumeStream ( false );
// one round-trip: straight to the stream — the page render already
// read the record; the replay rebuilds the partial regardless.
assert . deepEqual ( urls , [ "/api/chats/chat_x/stream" ]);
assert . equal ( m . getState (). status , "ready" );
// the mid-session caller (handoff watch) keeps the default refetch
// (the earlier test in this block pins that order).
});
2026-10-02 02:42:25 +03:00
it ( "OPTIMISTIC SUBMIT (owner, optimistic round 2026-10-02): send flips status synchronously — the placeholder is live before the server answers anything" , async () => {
2026-10-02 02:23:20 +03:00
let release !: ( r : Response ) => void ;
const gate = new Promise < Response >(( res ) => ( release = res ));
globalThis . fetch = ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
2026-10-02 02:29:24 +03:00
if ( url === "/api/chats/chat_x" ) return gate ; // the POST hangs
2026-10-02 02:23:20 +03:00
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
const m = model ();
2026-10-02 02:29:24 +03:00
// send() runs synchronously to its first await — status is already
// "submitted" (and the optimistic user bubble is appended) before
// the server has answered ANYTHING.
const p = m . send ({ type : "text" , text : "q" });
assert . equal ( m . getState (). status , "submitted" , "the placeholder is live pre-first-chunk" );
assert . equal ( m . getState (). messages . at ( - 1 ) ! . role , "user" , "the optimistic bubble is up" );
2026-10-02 02:23:20 +03:00
release ( Response . json ({ messages : [] }));
await p ;
2026-10-02 02:29:24 +03:00
// the reattach path is the same synchronous flip (resumeStream sets
2026-10-02 02:42:25 +03:00
// submitted at entry, before its fetches).
});
2026-10-02 03:00:56 +03:00
it ( "PROTOCOL EVENTS RELEASE NOTHING (owner, optimistic round 3): agent_start keeps the placeholder; the first APPLIED event flips streaming" , async () => {
// The round-2 bug chain, fixed at the machine: status used to flip
// streaming at the first wire BYTE (agent_start, emitted before the
// provider has produced anything), vanishing the placeholder into an
// empty wait. Now the flip waits for an event applyWireEvent ACTS
// on — the placeholder (gated on submitted) covers the whole
// provider-connect gap, and the applied event's message renders the
// thinking row even when its content is empty (view.ts projection).
let releaseProtocol !: () => void ;
let releaseRest !: () => void ;
const protocolGate = new Promise < void >(( res ) => ( releaseProtocol = res ));
const restGate = new Promise < void >(( res ) => ( releaseRest = res ));
2026-10-02 02:42:25 +03:00
const body = new ReadableStream < Uint8Array >({
async start ( c ) {
const enc = new TextEncoder ();
c . enqueue ( enc . encode ( sse ({ type : "agent_start" }, { type : "turn_start" })));
2026-10-02 03:00:56 +03:00
await protocolGate ; // the provider is slow — nothing but protocol so far
c . enqueue ( enc . encode ( sse ({ type : "message_start" , message : { role : "assistant" , content : [] } })));
await restGate ; // the message arrived; its first token is still pending
2026-10-02 02:42:25 +03:00
c . enqueue (
enc . encode (
2026-10-02 03:00:56 +03:00
sse ({ type : "message_update" , message : { role : "assistant" , content : [{ type : "thinking" , thinking : "pondering" }] } }),
2026-10-02 02:42:25 +03:00
),
);
c . enqueue ( enc . encode ( sse ({ type : "agent_end" , messages : [] }, "[DONE]" )));
c . close ();
},
});
2026-10-10 09:42:47 +03:00
globalThis . fetch = dietFetch ({ userText : "q" , stream : () => new Response ( body ) });
2026-10-02 02:42:25 +03:00
const m = model ();
const p = m . send ({ type : "text" , text : "q" });
2026-10-02 03:00:56 +03:00
assert . equal ( m . getState (). status , "submitted" , "the action is optimistic: placeholder up pre-everything" );
await new Promise (( r ) => setTimeout ( r , 25 )); // agent_start + turn_start consumed
assert . equal (
m . getState (). status ,
"submitted" ,
"protocol events apply NOTHING — the placeholder holds through the connect gap" ,
);
releaseProtocol ();
await new Promise (( r ) => setTimeout ( r , 25 )); // message_start consumed
assert . equal ( m . getState (). status , "streaming" , "the first APPLIED event flips streaming — the thinking row renders" );
releaseRest ();
2026-10-02 02:42:25 +03:00
await p ;
assert . equal ( m . getState (). status , "ready" );
});
2026-10-10 12:19:31 +03:00
it ( "SESSION START SURVIVES A STOP() RACING THE SUBMIT (owner, 2026-10-10 — the dead-practice-start postmortem)" , async () => {
// React StrictMode's simulated remount fires the unmount cleanup
// (~8ms in) while the ?prompt first send's POST is in flight — the
// exact live repro: pre-fix, the POST rode consumeAbort and died
// with AbortError, the page's sentInitial guard never retried, and
// the session sat dead until a second message (which then persisted
// WITHOUT the first). The law: submits are not abortable by view
// teardown — the POST completes, the turn is server-side, and the
// dead view attaches no stream.
let resolvePost !: () => void ;
let postAborted = false ;
const fetches : string [] = [];
globalThis . fetch = ( async ( input : RequestInfo | URL , init? : RequestInit ) => {
const url = String ( input );
fetches . push ( url );
if ( url === "/api/chat" ) {
init ? . signal ? . addEventListener ( "abort" , () => {
postAborted = true ;
});
await new Promise < void >(( r ) => {
resolvePost = () => r ();
});
return Response . json ({ ok : true });
}
if ( url === "/api/chats/chat_x" ) {
return Response . json ({ messages : [{ id : "u1" , role : "user" , content : "quiz me" , timestamp : 1 }] });
}
if ( url === "/api/chats/chat_x/stream" ) return new Response ( "data: [DONE]\n\n" , { status : 200 });
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
const m = model ();
const sending = m . send ({ type : "text" , text : "quiz me" });
await new Promise (( r ) => setTimeout ( r , 5 )); // the POST is in flight
m . stop (); // StrictMode's simulated unmount lands mid-submit
resolvePost (); // the ack completes anyway
await sending ;
assert . equal ( postAborted , false , "the submit POST carries no teardown signal at all" );
assert . ok ( fetches . includes ( "/api/chat" ), "the submit reached the server" );
assert . ok ( ! fetches . some (( u ) => u . endsWith ( "/stream" )), "a dead view attaches no stream — the turn is server-side" );
assert . equal ( m . getState (). status , "ready" );
});
2026-10-02 02:42:25 +03:00
2026-10-02 03:00:56 +03:00
it ( "REATTACH REPLAYS ONTO A CLEAN SLATE (owner, optimistic round 3): an aborted attempt's partials never double" , async () => {
// StrictMode's simulated remount aborts the first resume mid-replay;
// its partial tail survived into the retry and the replay DOUBLED
// every message (the record refetch's accidental tail reset died
// with attach-first). resumeStream now wipes the tail itself.
let hold !: () => void ;
const holdGate = new Promise < void >(( res ) => ( hold = res ));
let calls = 0 ;
const enc = new TextEncoder ();
const replay = () => {
calls ++ ;
if ( calls === 1 )
return new Response (
new ReadableStream < Uint8Array >({
async start ( c ) {
c . enqueue (
enc . encode (
sse ({ type : "message_update" , message : { role : "assistant" , content : [{ type : "text" , text : "part one" }] } }),
),
);
await holdGate ; // aborted here (the partial stays in the tail)
c . enqueue ( enc . encode ( sse ({ type : "agent_end" , messages : [] }, "[DONE]" )));
c . close ();
},
}),
{ headers : { "Content-Type" : "text/event-stream" } },
2026-10-02 02:42:25 +03:00
);
2026-10-02 03:00:56 +03:00
return new Response (
new ReadableStream < Uint8Array >({
async start ( c ) {
c . enqueue (
enc . encode (
sse (
{ type : "message_start" , message : { role : "assistant" , content : [{ type : "text" , text : "part one" }] } },
{ type : "agent_end" , messages : [] },
"[DONE]" ,
),
),
);
c . close ();
},
}),
{ headers : { "Content-Type" : "text/event-stream" } },
);
};
globalThis . fetch = ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
if ( url === "/api/chats/chat_x/stream" ) return replay ();
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
2026-10-02 02:42:25 +03:00
const m = model ();
2026-10-02 03:00:56 +03:00
const attempt1 = m . resumeStream ( false );
await new Promise (( r ) => setTimeout ( r , 25 )); // attempt 1 consumed its partial
// the simulated remount: attempt 2 aborts attempt 1 mid-replay
const attempt2 = m . resumeStream ( false );
hold (); // attempt 1's gate resolves after it was already aborted
await Promise . allSettled ([ attempt1 , attempt2 ]);
const texts = m
. getState ()
. messages . flatMap (( msg ) => msg . parts . filter (( p ) => p . type === "text" ). map (( p ) => ( p as { text? : string }). text ));
assert . deepEqual ( texts , [ "part one" ], "the replayed turn appears exactly ONCE — no doubles" );
2026-10-02 02:23:20 +03:00
});
2026-09-26 01:52:47 +03:00
it ( "turn-settled fires the settle hook and ends cleanly" , async () => {
let settled = 0 ;
globalThis . fetch = ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
if ( url === "/api/chats/chat_x" ) return Response . json ({ messages : [] });
if ( url === "/api/chats/chat_x/stream" ) return dripped ( sse ({ type : "turn-settled" }, "[DONE]" ), 8 );
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
const m = model ({ onReattachSettled : () => settled ++ });
await m . resumeStream ();
assert . equal ( settled , 1 );
assert . equal ( m . getState (). status , "ready" );
assert . equal ( m . getState (). error , null );
});
it ( "a replayed CONTINUATION mounts BESIDE the seeded message (the SDK replace-last seam is dead)" , async () => {
// The seeded record ends with an assistant message; the replayed
// turn continues with its OWN assistant message — the view groups
// them as one document with a step separator (pixel-identical to a
// live continuation; the settle sync lands the canon).
globalThis . fetch = ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
if ( url === "/api/chats/chat_x" ) return Response . json ({ messages : [ assistantMsg ( "seed" , "the question" )] });
if ( url === "/api/chats/chat_x/stream" )
return sseResponse (
2026-09-26 02:25:04 +03:00
{ type : "message_start" , message : { role : "assistant" , content : [{ type : "text" , text : "the reaction" }] } },
{ type : "message_end" , message : { role : "assistant" , content : [{ type : "text" , text : "the reaction" }], stopReason : "stop" } },
2026-09-26 01:52:47 +03:00
{ type : "agent_end" , messages : [] },
"[DONE]" ,
);
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
const m = model ({ initialMessages : [ assistantMsg ( "seed" , "the question" )] });
await m . resumeStream ();
const view = m . getState (). messages ;
assert . equal ( view . length , 1 , "one continuous assistant document" );
assert . deepEqual (
view [ 0 ] ! . parts . map (( p ) => p . type ),
[ "step-start" , "text" , "step-start" , "text" ],
"seeded message keeps rendering; the run mounts with a step separator" ,
);
});
it ( "survives a body split byte-by-byte across reads (the framing pin)" , async () => {
globalThis . fetch = ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
if ( url === "/api/chats/chat_x" ) return Response . json ({ messages : [] });
if ( url === "/api/chats/chat_x/stream" )
return dripped (
sse (
2026-09-26 02:25:04 +03:00
{ type : "message_start" , message : { role : "assistant" , content : [] } },
partial ([{ type : "text" , text : "hi" }]),
{ type : "message_end" , message : { role : "assistant" , content : [{ type : "text" , text : "hi" }], stopReason : "stop" } },
2026-09-26 01:52:47 +03:00
) + sse ( "[DONE]" ),
1 ,
);
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
const m = model ();
await m . resumeStream ();
assert . equal ( m . getState (). messages . at ( - 1 ) ! . parts . at ( - 1 ) ! . text , "hi" );
});
it ( "an unhappy endpoint surfaces the error banner" , async () => {
globalThis . fetch = ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
if ( url === "/api/chats/chat_x" ) return Response . json ({ messages : [] });
if ( url === "/api/chats/chat_x/stream" ) return new Response ( "boom" , { status : 500 });
throw new Error ( `unexpected fetch: ${ url } ` );
}) as typeof globalThis . fetch ;
const m = model ();
await m . resumeStream ();
const state = m . getState ();
assert . equal ( state . status , "error" );
assert . match ( state . error ! . message , /boom/ );
});
});
// ─── adoption (append-only ground rules, owner 2026-09-15) ──────────────────
function userText ( id : string , text : string ) : LhMessage {
return { ... newUserMessage ( text ), id };
}
describe ( "adoptRecord — append-only adoption" , () => {
it ( "splices a server-added steer between the record and the live tail" , () => {
const m = model ({ initialMessages : [ userText ( "u1" , "topic" )] });
2026-09-26 02:25:04 +03:00
m . applyWireEvent ({ type : "message_start" , message : { role : "assistant" , content : [{ type : "text" , text : "streaming" }] } });
2026-09-26 01:52:47 +03:00
const steer = { ... newUserMessage ( "steered content" ), id : "steer_1" } as LhMessage & { role : "user" };
const record = [ userText ( "u1" , "topic" ), steer ];
m . adoptRecord ( record );
2026-09-26 02:25:04 +03:00
const ids = m . getState (). nativeMessages . map (( x ) => x . id );
assert . deepEqual ( ids . slice ( 0 , 2 ), [ "u1" , "steer_1" ], "the steer shows NOW" );
assert . match ( ids [ 2 ] ?? "" , /^c_/ , "the live streaming tail stays last (client slot id)" );
2026-09-26 01:52:47 +03:00
});
it ( "a missing base id (record rewrite) bails — the idle full sync is the net" , () => {
const m = model ({ initialMessages : [ userText ( "u1" , "a" ), userText ( "u2" , "b" )] });
m . adoptRecord ([ userText ( "u2" , "b" )]); // u1 vanished: a rewrite, not an append
assert . deepEqual (
m . getState (). nativeMessages . map (( x ) => x . id ),
[ "u1" , "u2" ],
"the adoption is a no-op on a non-append" ,
);
});
it ( "absorbs the optimistic entries the record has taken over (no doubled bubbles)" , () => {
// A text send's optimistic bubble (local c_ id): the write-ahead's
// server copy absorbs it by CONTENT — the bubble never doubles.
const optimistic = { ... newUserMessage ( "my question" ), id : "c_local_1" } as LhMessage ;
const serverCopy = { ... newUserMessage ( "my question" ), id : "srv_9" } as LhMessage ;
assert . ok ( absorbed ( optimistic , [ userText ( "u1" , "topic" ), serverCopy ]), "content match absorbs the local copy" );
assert . ok ( ! absorbed ( serverCopy , [ optimistic ]), "server stamps are never absorbed" );
assert . ok ( ! absorbed ({ ... newUserMessage ( "my question" ) }, [ serverCopy ]), "an id-less message is never absorbed" );
// The answer result absorbs by toolCallId (ids differ, content may too —
// the toolCallId is the identity of an answer).
const answer = { ... newAnswerResult ( "call-1" , "quiz" , { correct : true }), id : "c_local_2" } as LhMessage ;
assert . ok (
absorbed ( answer , [{ ... newAnswerResult ( "call-1" , "quiz" , { correct : true }), id : "srv_10" } as LhMessage ]),
"the answer result absorbs by toolCallId" ,
);
});
it ( "setMessages is the full snapshot swap (the idle settle sync)" , () => {
const m = model ({ initialMessages : [ userText ( "u1" , "old" )] });
m . setMessages ([ userText ( "u1" , "old" ), userText ( "u2" , "new" ), assistantMsg ( "a1" , "reply" )]);
assert . deepEqual (
m . getState (). nativeMessages . map (( x ) => ( x as { id? : string }). id ),
[ "u1" , "u2" , "a1" ],
);
});
});
// ─── the continuation fold (mount/sync seam) ────────────────────────────────
function assistantWithCall ( id : string , callId : string , name = "research" ) : LhMessage {
return {
id ,
role : "assistant" ,
content : [{ type : "toolCall" , id : callId , name , arguments : { topic : "x" } }],
stopReason : "toolUse" ,
usage : { input : 0 , output : 0 , total : 0 , cacheReads : 0 , cacheWrites : 0 },
provider : "openrouter" ,
model : "test" ,
timestamp : 0 ,
} as unknown as LhMessage ;
}
function resultMsg ( callId : string , name = "research" ) : LhMessage {
return {
... newAnswerResult ( callId , name , { ok : true }),
id : `r_ ${ callId } ` ,
} as unknown as LhMessage ;
}
describe ( "needsContinuation (the native lastAssistantMessageIsCompleteWithToolCalls)" , () => {
it ( "a record ending on a fully-answered tool call continues" , () => {
const messages = [ userText ( "u1" , "topic" ), assistantWithCall ( "a1" , "call-r1" ), resultMsg ( "call-r1" )];
assert . equal ( needsContinuation ( messages ), true );
});
it ( "text-tailed records never continue" , () => {
const messages = [ userText ( "u1" , "topic" ), assistantMsg ( "a1" , "done talking" )];
assert . equal ( needsContinuation ( messages ), false );
});
it ( "a dangling question never fires a turn (the dock owns it)" , () => {
const messages = [ userText ( "u1" , "topic" ), assistantWithCall ( "a1" , "call-q1" , "quiz" )];
assert . equal ( needsContinuation ( messages ), false );
});
it ( "parallel quizzes: only ALL-answered continues (the dock still owns the open one)" , () => {
const messages = [
userText ( "u1" , "topic" ),
{
... assistantWithCall ( "a1" , "call-a" ),
content : [
{ type : "toolCall" , id : "call-a" , name : "quiz" , arguments : {} },
{ type : "toolCall" , id : "call-b" , name : "quiz" , arguments : {} },
],
} as unknown as LhMessage ,
resultMsg ( "call-a" , "quiz" ),
];
assert . equal ( needsContinuation ( messages ), false );
const both = [... messages , resultMsg ( "call-b" , "quiz" )];
assert . equal ( needsContinuation ( both ), true , "once the dock answers B, the agent owes the reaction" );
});
it ( "an orphan result (no asking assistant) never continues" , () => {
assert . equal ( needsContinuation ([ userText ( "u1" , "topic" ), resultMsg ( "call-x" )]), false );
});
});
2026-10-03 01:51:13 +03:00
// ─── the dismissal pause (owner design 2026-10-03, "A2") ────────────────────
describe ( "the dismissal pause (needsContinuation + endsInDismissal)" , () => {
/** The record's silent dismiss write: the toolResult the dismiss route
* appends (full answer in details — the folds read it there). */
function dismissedMsg ( callId : string ) : LhMessage {
return {
... newAnswerResult ( callId , "quiz" , dismissedAnswer ( "quiz" , "Pick a planet" )),
id : `d_ ${ callId } ` ,
} as unknown as LhMessage ;
}
it ( "an all-dismissed tail demands NO continuation — the session waits for the user" , () => {
const messages = [ userText ( "u1" , "topic" ), assistantWithCall ( "a1" , "call-q1" , "quiz" ), dismissedMsg ( "call-q1" )];
assert . equal ( needsContinuation ( messages ), false );
assert . equal ( endsInDismissal ( messages ), true , "the pause predicate reads the same tail" );
});
it ( "parallel dismissals: each silent write its own call — still paused" , () => {
const assistant = {
... assistantWithCall ( "a1" , "call-a" ),
content : [
{ type : "toolCall" , id : "call-a" , name : "quiz" , arguments : {} },
{ type : "toolCall" , id : "call-b" , name : "ask_user_question" , arguments : {} },
],
} as unknown as LhMessage ;
const messages = [
userText ( "u1" , "topic" ),
assistant ,
dismissedMsg ( "call-a" ),
{ ... newAnswerResult ( "call-b" , "ask_user_question" , dismissedAnswer ( "ask" , "Why?" )), id : "d_call-b" } as unknown as LhMessage ,
];
assert . equal ( endsInDismissal ( messages ), true );
assert . equal ( needsContinuation ( messages ), false , "dismissed by id, not by position — the batch rule holds" );
});
2026-10-03 02:27:44 +03:00
it ( "mixed tail answered-then-dismissed: the LAST resolution decides — no wake (round-1 incident)" , () => {
const assistant = {
... assistantWithCall ( "a1" , "call-a" ),
content : [
{ type : "toolCall" , id : "call-a" , name : "quiz" , arguments : {} },
{ type : "toolCall" , id : "call-b" , name : "quiz" , arguments : {} },
],
} as unknown as LhMessage ;
const messages = [ userText ( "u1" , "topic" ), assistant , resultMsg ( "call-a" , "quiz" ), dismissedMsg ( "call-b" )];
assert . equal ( endsInDismissal ( messages ), true , "tail ends in the dismissal" );
assert . equal ( needsContinuation ( messages ), false , "ending on a dismissal = the user signaled stop — the reaction rides the next message" );
});
it ( "mixed tail dismissed-then-answered: the answer IS owed its reaction (round-2 wake)" , () => {
2026-10-03 01:51:13 +03:00
const assistant = {
... assistantWithCall ( "a1" , "call-a" ),
content : [
{ type : "toolCall" , id : "call-a" , name : "quiz" , arguments : {} },
{ type : "toolCall" , id : "call-b" , name : "quiz" , arguments : {} },
],
} as unknown as LhMessage ;
2026-10-03 02:27:44 +03:00
const messages = [ userText ( "u1" , "topic" ), assistant , dismissedMsg ( "call-a" ), resultMsg ( "call-b" , "quiz" )];
assert . equal ( endsInDismissal ( messages ), false );
assert . equal ( needsContinuation ( messages ), true , "ending on a real answer = the reaction is owed" );
2026-10-03 01:51:13 +03:00
});
it ( "any user message un-pauses (the derived tail flips)" , () => {
const messages = [
userText ( "u1" , "topic" ),
assistantWithCall ( "a1" , "call-q1" , "quiz" ),
dismissedMsg ( "call-q1" ),
userText ( "u2" , "moving on" ),
];
assert . equal ( endsInDismissal ( messages ), false );
});
it ( "a normal answer is not a dismissal (the predicate stays honest)" , () => {
const messages = [ userText ( "u1" , "topic" ), assistantWithCall ( "a1" , "call-q1" , "quiz" ), resultMsg ( "call-q1" , "quiz" )];
assert . equal ( endsInDismissal ( messages ), false );
});
});
2026-09-26 01:52:47 +03:00
// ─── the pending-call tool scan ─────────────────────────────────────────────
describe ( "pendingCallTool" , () => {
it ( "finds the dangling call's tool name (the optimistic answer's stamp)" , () => {
const messages = [ assistantWithCall ( "a1" , "call-q1" , "ask_user_question" )];
assert . equal ( pendingCallTool ( messages , "call-q1" ), "ask_user_question" );
assert . equal ( pendingCallTool ( messages , "call-zz" ), null );
});
});
2026-10-02 04:04:04 +03:00
// ─── newAnswerResult: the two-audience split (owner, 2026-10-02) ────────────
describe ( "newAnswerResult two audiences" , () => {
it ( "MODEL sees the compact learner-only record; details keeps the FULL graded answer" , () => {
const qd : QuestionData = {
id : "call-1" ,
kind : "quiz" ,
question : "Which planet is closest to the Sun?" ,
mode : "single-select" ,
options : [
{ label : "Mercury" , value : "mercury" },
{ label : "Venus" , value : "venus" },
],
correctIndices : [ 1 ],
explanation : "Mercury orbits closest." ,
createdAt : "2026-01-01T00:00:00Z" ,
};
2026-10-04 00:53:29 +03:00
const full = buildAnswer ( qd , { values : [ "mercury" ], confidence : 70 , thinking : "guessed" });
2026-10-02 04:04:04 +03:00
const msg = newAnswerResult ( "call-1" , "quiz" , full );
const modelSees = JSON . parse (( msg . content as { type : "text" ; text : string }[])[ 0 ] ! . text );
assert . deepEqual ( modelSees , {
kind : "quiz" ,
answers : [{ index : 1 , label : "Mercury" }],
correct : true ,
confidence : 70 ,
2026-10-04 00:53:29 +03:00
thinking : "guessed" ,
2026-10-02 04:04:04 +03:00
});
assert . equal ( msg . details , full , "details is the full QuestionAnswerData verbatim" );
assert . equal (( msg . details as { questionText? : string }). questionText , "Which planet is closest to the Sun?" );
});
});
2026-10-05 01:02:43 +03:00
// ─── the practice live-view law (owner, 2026-10-05) ─────────────────────────
//
// The two-spinners + doubled-card incident: submitting thinking flipped the
// POSE turn's problem call back to a live spinner mid-transcript (session
// busyness leaked into per-call state), and the optimistic thinking marker
// was never absorbed (role "custom" was missing from absorbed()), so the
// adoption window rendered the submission twice for the whole turn.
/** The post-settle pose-turn record of a practice session. */
function poseRecord () : LhMessage [] {
return [
{ id : "m1" , role : "user" , content : "db practice" },
{
id : "m3" ,
role : "assistant" ,
content : [{ type : "toolCall" , id : "call-p1" , name : "problem" , arguments : { statement : "Q?" } }],
} as unknown as LhMessage ,
{
id : "m4" ,
role : "toolResult" ,
toolCallId : "call-p1" ,
toolName : "problem" ,
content : [{ type : "text" , text : "Problem posed — the turn ends here." }],
details : null ,
} as unknown as LhMessage ,
] as LhMessage [];
}
describe ( "the live-view law — live is positive evidence, not session busyness" , () => {
it ( "a settled problem call NEVER renders live during another turn (the two-spinner bug)" , () => {
// The thinking turn's live state: inFlight, but NOTHING streams that
// touches the pose call, and the executions map (per-turn) is empty.
const live = { executions : new Map (), inFlight : true , streamingIds : new Set < string >() };
const view = projectView ( poseRecord (), live );
const problem = view . flatMap (( m ) => m . parts ). find (( p ) => p . type === "tool-problem" );
assert . ok ( problem , "the problem part projects" );
assert . equal ( problem ! . state , "input-available" , "settled = the ProblemCard, never a shimmer" );
});
it ( "a call in THIS turn's streaming message stays input-streaming (the shimmer's true home)" , () => {
const live = { executions : new Map (), inFlight : true , streamingIds : new Set ([ "c_as1" ]) };
const messages = [
... poseRecord (),
{
id : "c_as1" ,
role : "assistant" ,
content : [{ type : "toolCall" , id : "call-c1" , name : "conclude" , arguments : { outcome : "solved" } }],
} as unknown as LhMessage ,
];
const view = projectView ( messages , live );
const conclude = view . flatMap (( m ) => m . parts ). find (( p ) => p . type === "tool-conclude" );
assert . ok ( conclude , "the conclude part projects" );
assert . equal ( conclude ! . state , "input-streaming" , "the live card renders at the bottom, by construction" );
});
it ( "a DANGLING call in a settled message is the dock's pending — not a mid-turn shimmer" , () => {
const live = { executions : new Map (), inFlight : true , streamingIds : new Set < string >() };
const messages = [
{ id : "m1" , role : "user" , content : "go" },
{
id : "m2" ,
role : "assistant" ,
content : [{ type : "toolCall" , id : "call-p9" , name : "problem" , arguments : { statement : "Q?" } }],
} as unknown as LhMessage ,
] as LhMessage [];
const view = projectView ( messages , live );
const part = view . flatMap (( m ) => m . parts ). find (( p ) => p . type === "tool-problem" );
assert . equal ( part ! . state , "input-available" , "no evidence, no shimmer — even mid-turn" );
});
});
describe ( "absorbed — the custom role (the doubled thinking card)" , () => {
it ( "absorbs the optimistic thinking marker by customType + details" , () => {
2026-10-05 03:46:32 +03:00
const optimistic = { ... newThinkingMessage ( "call-p1" , "my trace" ), id : "c_opt1" } as LhMessage ;
const serverCopy = { ... newThinkingMessage ( "call-p1" , "my trace" ), id : "srv_1" } as LhMessage ;
2026-10-05 01:02:43 +03:00
assert . ok ( absorbed ( optimistic , [... poseRecord (), serverCopy ]), "same submission → the local copy drops" );
2026-10-05 03:46:32 +03:00
// A DIFFERENT submission (different text) is its own marker.
const other = { ... newThinkingMessage ( "call-p1" , "a later, different trace" ), id : "srv_2" } as LhMessage ;
2026-10-05 01:02:43 +03:00
assert . ok ( ! absorbed ( optimistic , [... poseRecord (), other ]), "different details → not the same submission" );
// The c_ gate: server stamps are never absorbed, whatever the role.
assert . ok ( ! absorbed ( serverCopy , [ optimistic ]), "server ids never drop" );
});
it ( "the adoption window cannot double the card (model-level end-to-end)" , () => {
// The exact client sequence of the incident: the optimistic marker in
// the tail (client id), then the +400ms adoption fetch swaps in the
// write-ahead record (server id). ONE card must remain.
const m = model ({ chatId : "chat_x" , initialMessages : poseRecord () });
2026-10-05 03:46:32 +03:00
const optimistic = { ... newThinkingMessage ( "call-p1" , "my trace" ), id : "c_opt1" } as LhMessage ;
2026-10-05 01:02:43 +03:00
m . applyWireEvent ({ type : "message_start" , message : optimistic });
assert . equal (
m . getState (). nativeMessages . filter (( x ) => ( x as { role? : string }). role === "custom" ). length ,
1 ,
"the optimistic marker is in the tail" ,
);
2026-10-05 03:46:32 +03:00
const serverCopy = { ... newThinkingMessage ( "call-p1" , "my trace" ), id : "srv_1" } as LhMessage ;
2026-10-05 01:02:43 +03:00
m . adoptRecord ([... poseRecord (), serverCopy ]);
const customs = m . getState (). nativeMessages . filter (( x ) => ( x as { role? : string }). role === "custom" );
assert . equal ( customs . length , 1 , "the server copy replaced the optimistic one — no doubling" );
assert . equal (( customs [ 0 ] as { id? : string }). id , "srv_1" );
});
});