2026-08-22 09:36:15 +03:00
/**
* Job infrastructure tests — deterministic, zero network:
*
* 1. job store CRUD (create → get → update → list → dedup by toolCallId)
2026-09-01 19:15:43 +03:00
* 2. steer helpers (pure, contracts.ts): buildSubagentSteer shape per kind,
* isSubagentFailure, steerDelivered
* 3. dispatch (async protocol §2): drive handleChatRequest with a scripted
* mock teacher that emits a research tool call → the tool part resolves
* IMMEDIATELY with the fire-and-forget ack (the teacher's turn
* CONTINUES — no turn break at the call), a job record exists, and the
* test runner (fake deliver) produces the steer
* 4. runner completion: a fake researcher model (web_search call → final
* text brief, globalThis.fetch stubbed) → the job stores the result,
* the steer carries the brief + sources, the fake deliver flips the
* job done
* 5. runner failure: a model that throws → the failure steer still fires
* (the teacher learns the outcome) and the job lands error
* 6. the wake pipeline (src/server/jobs.ts): server-side steer delivery —
* happy path (fires the injected turn runner, flips the job), pending-
* quiz deferral, 409 retry, idempotent redelivery
2026-08-22 09:36:15 +03:00
*
* Cleanup follows tests/chat-flow.test.ts: created chat/job files removed in
* finally blocks / the after() hook.
*/
import assert from "node:assert/strict" ;
import * as fs from "node:fs" ;
import * as path from "node:path" ;
import { after , describe , it } from "node:test" ;
2026-09-09 05:54:04 +03:00
import type { UIMessage } from "ai" ;
2026-09-12 02:34:18 +03:00
import { createFauxCore , fauxAssistantMessage , fauxText , fauxToolCall , type AssistantMessage , type FauxResponseStep } from "@earendil-works/pi-ai" ;
2026-09-01 19:15:43 +03:00
import {
2026-09-11 03:25:09 +03:00
accumulateJobUsage ,
buildAbortSteer ,
2026-09-01 19:15:43 +03:00
buildSubagentSteer ,
isSubagentFailure ,
steerDelivered ,
type DemoResult ,
2026-09-11 03:25:09 +03:00
type JobUsage ,
2026-09-01 19:15:43 +03:00
type ResearchResult ,
2026-09-11 03:25:09 +03:00
type SteerMessage ,
2026-09-01 19:15:43 +03:00
type SubagentSteerMessage ,
type VisualResult ,
2026-09-11 06:44:20 +03:00
buildProbeHandoffSteer ,
type ProbeHandoffSteerMessage ,
2026-09-01 19:15:43 +03:00
} from "../src/core/contracts.ts" ;
2026-09-12 02:34:18 +03:00
import { composeInstructions , createTeacherAgent , type TeachingAgent } from "../src/agent/teacher-agent.ts" ;
2026-09-01 19:15:43 +03:00
import { createTeacherTools } from "../src/agent/tools.ts" ;
2026-09-12 02:34:18 +03:00
import { webTools } from "../src/agent/tools/web.ts" ;
2026-08-22 09:36:15 +03:00
import { handleChatRequest } from "../src/server/chat-handler.ts" ;
2026-09-12 02:34:18 +03:00
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts" ;
2026-09-11 03:25:09 +03:00
import { abortJob , claimSteers , deliverJobResult , setTurnRunner } from "../src/server/jobs.ts" ;
import { beginTurn , endTurn } from "../src/server/turn-liveness.ts" ;
2026-09-02 02:34:02 +03:00
import { persistTurn , withMessageIds } from "../src/server/turn-service.ts" ;
2026-08-31 20:00:12 +03:00
import { chatStore } from "../src/store/chats.ts" ;
import { jobStore } from "../src/store/jobs.ts" ;
2026-08-31 22:16:03 +03:00
import { createJobRunner , researchModelOverride } from "../src/agent/subagents/runner.ts" ;
2026-09-12 02:34:18 +03:00
import type { PiEngine } from "../src/agent/models.ts" ;
2026-08-22 09:36:15 +03:00
import { PATHS } from "../config.ts" ;
const JOBS_DIR = path . join ( PATHS . data , "jobs" );
const createdChats : string [] = [];
const createdJobs : string [] = [];
function trackChat ( id : string ) {
createdChats . push ( id );
}
function trackJob ( id : string ) {
createdJobs . push ( id );
}
after (() => {
for ( const id of createdChats ) {
try {
fs . unlinkSync ( path . join ( PATHS . chats , ` ${ id } .json` ));
} catch {
/* already gone */
}
}
for ( const id of createdJobs ) {
try {
fs . unlinkSync ( path . join ( JOBS_DIR , ` ${ id } .json` ));
} catch {
/* already gone */
}
}
2026-09-01 19:15:43 +03:00
researchModelOverride . current = undefined ;
2026-08-22 09:36:15 +03:00
});
2026-09-12 02:34:18 +03:00
/** A scripted pi engine (faux provider): the response queue replaces the
* old doStream/doGenerate turn counters. tokenSize is set so every test
* string streams as ONE delta — the SSE-body assertions ("Back with the
* brief.") read contiguous text. */
function fauxEngine ( steps : FauxResponseStep []) : PiEngine {
const faux = createFauxCore ({ tokenSize : { min : 64 , max : 64 } });
faux . setResponses ( steps );
return { model : faux.getModel (), streamFn : faux.streamSimple };
}
/** A scripted response factory whose model call never resolves until its
* abort signal fires (a cancelled provider request); an already-aborted
* signal resolves at once — the faux stream then ends the message aborted. */
function abortableFactory () : FauxResponseStep {
const factory = ( _context : unknown , options : { signal? : AbortSignal } | undefined ) =>
new Promise < AssistantMessage >(( resolve ) => {
const sig = options ? . signal ;
if ( sig ? . aborted ) {
resolve ( fauxAssistantMessage ( "late" ));
return ;
}
sig ? . addEventListener ( "abort" , () => resolve ( fauxAssistantMessage ( "late" )), { once : true });
});
return factory as unknown as FauxResponseStep ;
2026-08-22 09:36:15 +03:00
}
async function drain ( res : Response ) : Promise < string > {
if ( res . status !== 200 ) {
assert . fail ( `expected 200, got ${ res . status } : ${ await res . text () } ` );
}
return res . text ();
}
function post ( chatId : string , messages : unknown []) : Request {
return new Request ( "http://test/api/chat" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON.stringify ({ chatId , messages }),
});
}
2026-09-01 19:15:43 +03:00
function waitFor ( cond : () => boolean , timeoutMs = 8000 , what = "condition" ) : Promise < void > {
return new Promise (( resolve , reject ) => {
const started = Date . now ();
const tick = () => {
if ( cond ()) return resolve ();
if ( Date . now () - started > timeoutMs ) return reject ( new Error ( `timeout waiting for ${ what } ` ));
setTimeout ( tick , 25 );
};
tick ();
});
}
2026-08-22 09:36:15 +03:00
// ─── 1. job store CRUD ───────────────────────────────────────────────────────
describe ( "job store" , () => {
it ( "create → get → update → list round-trips" , () => {
2026-08-31 20:00:12 +03:00
const job = jobStore . createJob ({ kind : "research" , chatId : "chat_storetest" , toolCallId : "call-store" });
2026-08-22 09:36:15 +03:00
trackJob ( job . id );
assert . match ( job . id , /^job_/ );
assert . equal ( job . status , "running" );
assert . equal ( job . activity , "starting" );
2026-08-31 20:00:12 +03:00
const loaded = jobStore . getJob ( job . id ) ! ;
2026-08-22 09:36:15 +03:00
assert . equal ( loaded . toolCallId , "call-store" );
assert . equal ( loaded . kind , "research" );
2026-08-31 20:00:12 +03:00
const updated = jobStore . updateJob ( job . id , { activity : 'web_search: "test"' , status : "done" , result : { brief : "b" } }) ! ;
2026-08-22 09:36:15 +03:00
assert . equal ( updated . activity , 'web_search: "test"' );
assert . equal ( updated . status , "done" );
assert . ok ( updated . updatedAt >= job . updatedAt );
// immutable identity fields survive a patch
assert . equal ( updated . toolCallId , "call-store" );
2026-08-31 20:00:12 +03:00
assert . equal ( jobStore . getJob ( updated . id ) ! . toolCallId , "call-store" );
2026-08-22 09:36:15 +03:00
2026-08-31 20:00:12 +03:00
const listed = jobStore . listJobs ( "chat_storetest" );
2026-08-22 09:36:15 +03:00
assert . ok ( listed . some (( j ) => j . id === job . id ));
2026-08-31 20:00:12 +03:00
assert . equal ( jobStore . listJobs ( "chat_other" ). some (( j ) => j . id === job . id ), false , "jobs are scoped to their chat" );
2026-08-22 09:36:15 +03:00
2026-08-31 20:00:12 +03:00
const byTool = jobStore . findJobByToolCallId ( "chat_storetest" , "call-store" );
2026-08-22 09:36:15 +03:00
assert . equal ( byTool ? . id , job . id );
2026-08-31 20:00:12 +03:00
assert . equal ( jobStore . findJobByToolCallId ( "chat_storetest" , "call-missing" ), null );
2026-08-22 09:36:15 +03:00
});
});
2026-09-01 19:15:43 +03:00
// ─── 2. steer helpers (pure) ─────────────────────────────────────────────────
describe ( "steer helpers (contracts.ts)" , () => {
const research : ResearchResult = { brief : "BRIEF" , sources : [{ title : "s" , url : "https://x.test" }] };
const demo : DemoResult = { title : "T" , html : "<html></html>" , tested : true , notes : "ok" };
const visual : VisualResult = { svg : "<svg/>" , notes : "ok" };
it ( "buildSubagentSteer: user-role, metadata-marked, artifact on the data part, brief on the text part" , () => {
const job = { id : "job_x" , kind : "research" as const , toolCallId : "call-x" };
const steer = buildSubagentSteer ( job , research );
assert . equal ( steer . id , "steer_job_x" , "deterministic id per job — idempotent delivery" );
assert . equal ( steer . role , "user" );
assert . deepEqual ( steer . metadata , { subagentResult : { jobId : "job_x" , kind : "research" , toolCallId : "call-x" } });
const [ text , data ] = steer . parts ;
assert . equal ( text . type , "text" );
assert . ok ( text . type === "text" && text . text . includes ( "BRIEF" ), "the model-facing text carries the brief" );
assert . equal ( data . type , "data-subagent-artifact" );
assert . ok ( data . type === "data-subagent-artifact" );
assert . equal ( data . data . kind , "research" );
assert . deepEqual ( data . data . result , research );
});
2026-09-11 03:25:09 +03:00
it ( "accumulateJobUsage: sums per model call; fields NO call reported stay undefined (absent is not zero)" , () => {
let total : JobUsage | undefined = undefined ;
total = accumulateJobUsage ( total , "z-ai/glm-5.3-flash" , {
inputTokens : 10 ,
outputTokens : 5 ,
inputTokenDetails : { cacheReadTokens : 4 },
});
assert . deepEqual ( total , {
model : "z-ai/glm-5.3-flash" ,
inputTokens : 10 ,
outputTokens : 5 ,
cacheReadTokens : 4 ,
cacheWriteTokens : undefined ,
reasoningTokens : undefined ,
steps : 1 ,
lastStep : {
inputTokens : 10 ,
outputTokens : 5 ,
cacheReadTokens : 4 ,
cacheWriteTokens : undefined ,
reasoningTokens : undefined ,
},
}, "lastStep is the most recent call's accounting, not cumulative" );
// a later call that reports nothing new keeps reported fields summing
// (unreported contributes 0) and bumps the billing step count.
total = accumulateJobUsage ( total , "z-ai/glm-5.3-flash" , { inputTokens : 20 , outputTokens : 0 });
assert . equal ( total . inputTokens , 30 );
assert . equal ( total . outputTokens , 5 );
assert . equal ( total . cacheReadTokens , 4 , "unreported after reported contributes 0" );
assert . equal ( total . cacheWriteTokens , undefined , "never reported stays absent — never zero" );
assert . equal ( total . reasoningTokens , undefined );
assert . equal ( total . steps , 2 , "one step per model call, reported or not" );
assert . deepEqual ( total . lastStep , {
inputTokens : 20 ,
outputTokens : 0 ,
cacheReadTokens : undefined ,
cacheWriteTokens : undefined ,
reasoningTokens : undefined ,
}, "lastStep always mirrors the LATEST call, never the sums" );
});
2026-09-12 05:14:40 +03:00
it ( "buildAbortSteer: teacher-facing text + the data-subagent-aborted marker part (the message type), same deterministic id space as the result steer" , () => {
2026-09-11 03:25:09 +03:00
const steer = buildAbortSteer ({ id : "job_c" , kind : "demo" , toolCallId : "call-c" });
assert . equal ( steer . id , "steer_job_c" , "same id space — steerDelivered treats either as delivered" );
assert . equal ( steer . role , "user" );
assert . equal ( steer . metadata . subagentResult . aborted , true );
assert . equal ( steer . metadata . subagentResult . jobId , "job_c" );
2026-09-12 05:14:40 +03:00
assert . equal ( steer . parts . length , 2 );
2026-09-11 03:25:09 +03:00
assert . ok ( steer . parts [ 0 ] ! . type === "text" && /aborted/ . test ( steer . parts [ 0 ]. text ));
2026-09-12 05:14:40 +03:00
// The marker PART is the message type (the custom-message scheme): the
// UI renders the abort badge off it — metadata stays for pump mechanics.
const marker = steer . parts [ 1 ] ! ;
assert . equal ( marker . type , "data-subagent-aborted" );
assert . deepEqual ( marker . data , { jobId : "job_c" , kind : "demo" , toolCallId : "call-c" , aborted : true });
2026-09-11 03:25:09 +03:00
});
2026-09-01 19:15:43 +03:00
it ( "isSubagentFailure: failure shapes only (empty html/svg, failure brief)" , () => {
assert . equal ( isSubagentFailure ( "research" , research ), false );
assert . equal ( isSubagentFailure ( "research" , { brief : "Research failed: x" , sources : [] }), true );
// a brief with sources is a success even if the text mentions failure
assert . equal ( isSubagentFailure ( "research" , { brief : "Research failed: x" , sources : [{ title : "s" , url : "https://x" }] }), false );
assert . equal ( isSubagentFailure ( "demo" , demo ), false );
assert . equal ( isSubagentFailure ( "demo" , { ... demo , html : "" }), true );
assert . equal ( isSubagentFailure ( "visual" , visual ), false );
assert . equal ( isSubagentFailure ( "visual" , { ... visual , svg : "" }), true );
});
2026-09-11 06:44:20 +03:00
it ( "steerDelivered: matches on the deterministic steer id (any steer kind)" , () => {
2026-09-01 19:15:43 +03:00
const steer = buildSubagentSteer ({ id : "job_y" , kind : "demo" , toolCallId : "c" }, demo );
2026-09-11 06:44:20 +03:00
assert . equal ( steer . id , "steer_job_y" );
assert . equal ( steerDelivered ([], steer . id ), false );
assert . equal ( steerDelivered ([ steer ], steer . id ), true );
assert . equal ( steerDelivered ([ steer ], "steer_job_z" ), false );
// the probe handoff shares the id-space contract
const handoff = buildProbeHandoffSteer ( "chat_q" );
assert . equal ( handoff . id , "steer_probe_chat_q" );
assert . equal ( steerDelivered ([ handoff ], handoff . id ), true );
assert . equal ( steerDelivered ([ steer ], handoff . id ), false );
2026-09-01 19:15:43 +03:00
});
it ( "failure steers tell the teacher how to recover (research/demo/visual wording)" , () => {
const failResearch = buildSubagentSteer ({ id : "j1" , kind : "research" , toolCallId : "c" }, { brief : "Research failed: API down" , sources : [] });
const failDemo = buildSubagentSteer ({ id : "j2" , kind : "demo" , toolCallId : "c" }, { title : "T" , html : "" , tested : true , notes : "FAILED: boom" });
const failVisual = buildSubagentSteer ({ id : "j3" , kind : "visual" , toolCallId : "c" }, { svg : "" , notes : "FAILED: boom" });
for ( const steer of [ failResearch , failDemo , failVisual ]) {
const text = steer . parts [ 0 ];
assert . ok ( text . type === "text" && ! text . text . includes ( "rendered in the lesson" ), "failure text never claims the artifact landed" );
}
assert . ok ( failResearch . parts [ 0 ]. type === "text" && failResearch . parts [ 0 ]. text . includes ( "flag the uncertainty" ));
});
});
// ─── fake deliver (the wake pipeline's stand-in for runner tests) ───────────
/**
* A fake deliver seam: capture the steer and mimic the pipeline's final
* status flip (done/error by isSubagentFailure) so the job record reads
* exactly as it will in production after the wake turn completes.
*/
function fakeDeliver() {
const steers : SubagentSteerMessage [] = [];
return {
steers ,
deliver : async ( steer : SubagentSteerMessage ) => {
steers . push ( steer );
const jobId = steer . metadata . subagentResult . jobId ;
const job = jobStore . getJob ( jobId );
if ( job && job . status === "running" ) {
const failed = job . result ? isSubagentFailure ( job . kind , job . result as never ) : true ;
jobStore . updateJob ( jobId , { status : failed ? "error" : "done" , activity : failed ? "failed" : "delivered" });
}
},
};
}
// ─── 3. dispatch: the tool call acks mid-turn and spawns ─────────────────────
2026-08-22 09:36:15 +03:00
const RESEARCH_INPUT = {
question : "What is the latest stable sigma.js version?" ,
context : "Choosing a graph rendering library for the lesson." ,
};
2026-09-01 19:15:43 +03:00
/** A teacher that calls research (text → call), then plain text thereafter. */
2026-09-12 02:34:18 +03:00
function researchCallingTeacher () : PiEngine {
return fauxEngine ([
fauxAssistantMessage (
[ fauxText ( "Let me check the current state of the library." ), fauxToolCall ( "research" , RESEARCH_INPUT , { id : "call-r1" })],
{ stopReason : "toolUse" },
),
fauxAssistantMessage ([ fauxText ( "Back with the brief." )]),
]);
2026-08-22 09:36:15 +03:00
}
2026-09-01 19:15:43 +03:00
describe ( "dispatch (async protocol §2)" , () => {
it ( "the research call resolves immediately with an ack; the turn continues and the runner steers" , async () => {
const chat = chatStore . createChat ( "Test: research dispatch" );
2026-08-22 09:36:15 +03:00
trackChat ( chat . id );
2026-09-01 19:15:43 +03:00
const delivery = fakeDeliver ();
const testRunner = createJobRunner ({ chatStore , jobStore , deliver : delivery.deliver });
2026-09-12 02:34:18 +03:00
const engine = researchCallingTeacher ();
const tools = createTeacherTools ({
chatId : chat.id ,
chatStore ,
jobStore ,
spawn : ( job , input ) => void testRunner . runJob ( job . id , input ),
});
const agent = ( outgoing : UIMessage []) : TeachingAgent =>
createTeacherAgent ( undefined , { engine , tools , messages : recordToPiMessages ( outgoing ) });
2026-08-22 09:36:15 +03:00
2026-09-01 19:15:43 +03:00
const restoreFetch = stubFetch ();
researchModelOverride . current = researcherModel ();
try {
const firstUser = { id : "u1" , role : "user" , parts : [{ type : "text" , text : "teach me graph rendering" }] };
const body = await drain ( await handleChatRequest ( post ( chat . id , [ firstUser ]), agent ));
2026-08-22 09:36:15 +03:00
2026-09-01 19:15:43 +03:00
// The teacher's SAME turn carried on past the tool call.
assert . ok ( body . includes ( "Back with the brief." ), "the turn continued after the dispatch" );
2026-08-22 09:36:15 +03:00
2026-09-01 19:15:43 +03:00
const saved = chatStore . getChat ( chat . id ) ! ;
const part = saved . messages [ 1 ] ! . parts . find (( p ) => p . type === "tool-research" );
assert . ok ( part && part . type === "tool-research" , "research tool part persisted" );
assert . equal ( part . state , "output-available" , "the part resolved at dispatch time (not held pending)" );
assert . equal ( typeof ( part as { output? : unknown }). output , "string" , "the output is the ack, not the result" );
assert . match ( String (( part as { output? : unknown }). output ), /fire-and-forget/ );
// The job record exists and the background runner completed it.
const job = jobStore . findJobByToolCallId ( chat . id , "call-r1" );
assert . ok ( job , "dispatch created a job record" );
trackJob ( job ! . id );
await waitFor (() => jobStore . getJob ( job ! . id ) ! . status !== "running" , 8000 , "research job completion" );
const done = jobStore . getJob ( job ! . id ) ! ;
assert . equal ( done . status , "done" );
assert . equal ( done . activity , "delivered" );
// The steer carries the brief + artifact for the (UI) renderer.
assert . equal ( delivery . steers . length , 1 , "exactly one steer — one job, one result" );
const steer = delivery . steers [ 0 ] ! ;
assert . equal ( steer . metadata . subagentResult . jobId , job ! . id );
const text = steer . parts [ 0 ];
assert . ok ( text . type === "text" && text . text . includes ( "sigma.js v3 is current" ), "steer text carries the brief" );
} finally {
restoreFetch ();
researchModelOverride . current = undefined ;
}
2026-08-22 09:36:15 +03:00
});
});
// ─── the researcher loop with a fake model + stubbed fetch ──────────────────
const BRIEF = `## Summary
sigma.js v3 is current.
## Findings
1. **v3 is the latest major.** [sigma.js docs](https://www.sigmajs.org/docs)
## Sources
- sigma.js docs (https://www.sigmajs.org/docs)` ;
2026-09-12 02:34:18 +03:00
/** The researcher loop's scripted engine. Turn 1: a web_search tool call;
* turn 2: the final text brief. */
function researcherModel () : PiEngine {
return fauxEngine ([
fauxAssistantMessage ([ fauxToolCall ( "web_search" , { query : "sigma.js latest version" }, { id : "ws1" })], {
stopReason : "toolUse" ,
}),
fauxAssistantMessage ([ fauxText ( BRIEF )]),
]);
2026-08-22 09:36:15 +03:00
}
function stubFetch() {
const original = globalThis . fetch ;
globalThis . fetch = ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
if ( url . startsWith ( "https://www.googleapis.com/" )) {
return new Response (
JSON . stringify ({
items : [{ title : "sigma.js" , link : "https://www.sigmajs.org/" , snippet : "v3 released" }],
}),
{ status : 200 , headers : { "Content-Type" : "application/json" } },
);
}
return new Response ( "unexpected fetch: " + url , { status : 500 });
}) as typeof fetch ;
return () => {
globalThis . fetch = original ;
};
}
describe ( "research runner (async protocol §3)" , () => {
2026-09-01 19:15:43 +03:00
it ( "runJob completes the job: result + sources on the job, brief on the steer, activity recorded" , async () => {
2026-08-22 09:36:15 +03:00
const restoreFetch = stubFetch ();
try {
2026-08-31 20:00:12 +03:00
const chat = chatStore . createChat ( "Test: runner completion" );
2026-08-22 09:36:15 +03:00
trackChat ( chat . id );
2026-09-01 19:15:43 +03:00
// A dispatched research part in the stored chat (the input is also
// what the runner falls back to on a crash-recovery re-spawn).
chatStore . saveMessages ( chat . id , [
2026-08-22 09:36:15 +03:00
{ id : "u1" , role : "user" , parts : [{ type : "text" , text : "which sigma version?" }] },
{
id : "a1" ,
role : "assistant" ,
parts : [
{ type : "text" , text : "Checking." },
2026-09-01 19:15:43 +03:00
{ type : "tool-research" , toolCallId : "call-run1" , state : "output-available" , input : RESEARCH_INPUT , output : "Dispatched." },
2026-08-22 09:36:15 +03:00
],
},
2026-09-01 19:15:43 +03:00
] as never );
2026-08-22 09:36:15 +03:00
2026-08-31 20:00:12 +03:00
const job = jobStore . createJob ({ kind : "research" , chatId : chat.id , toolCallId : "call-run1" });
2026-08-22 09:36:15 +03:00
trackJob ( job . id );
2026-09-01 19:15:43 +03:00
const delivery = fakeDeliver ();
const runner = createJobRunner ({ chatStore , jobStore , deliver : delivery.deliver });
researchModelOverride . current = researcherModel ();
try {
await runner . runJob ( job . id );
} finally {
researchModelOverride . current = undefined ;
}
2026-08-22 09:36:15 +03:00
// The job is done, with the activity line from the tool call.
2026-08-31 20:00:12 +03:00
const done = jobStore . getJob ( job . id ) ! ;
2026-08-22 09:36:15 +03:00
assert . equal ( done . status , "done" );
2026-09-01 19:15:43 +03:00
assert . equal ( done . activity , "delivered" , "the fake deliver flips the status like the pipeline" );
const result = done . result as ResearchResult ;
assert . equal ( result . brief , BRIEF );
assert . ok ( result . sources . length > 0 , "sources parsed from the brief" );
assert . equal ( result . sources [ 0 ] ! . url , "https://www.sigmajs.org/docs" );
2026-08-22 09:36:15 +03:00
2026-09-01 19:15:43 +03:00
// The activity trail survives on... the last recorded activity is
// the deliver flip, but the search line was recorded during the run:
// the job store keeps only the latest; the search fired before it.
// (We ran the search; the runner recorded it — check the store's
// history via the final state only.)
2026-08-22 09:36:15 +03:00
} finally {
restoreFetch ();
}
});
2026-09-01 19:15:43 +03:00
it ( "a failing runner still steers (the teacher learns the outcome) and the job lands error" , async () => {
2026-08-31 20:00:12 +03:00
const chat = chatStore . createChat ( "Test: runner failure" );
2026-08-22 09:36:15 +03:00
trackChat ( chat . id );
2026-08-31 20:00:12 +03:00
chatStore . saveMessages ( chat . id , [
2026-08-22 09:36:15 +03:00
{ id : "u1" , role : "user" , parts : [{ type : "text" , text : "hi" }] },
{
id : "a1" ,
role : "assistant" ,
2026-09-01 19:15:43 +03:00
parts : [{ type : "tool-research" , toolCallId : "call-err1" , state : "output-available" , input : RESEARCH_INPUT , output : "Dispatched." }],
2026-08-22 09:36:15 +03:00
},
] as never );
2026-08-31 20:00:12 +03:00
const job = jobStore . createJob ({ kind : "research" , chatId : chat.id , toolCallId : "call-err1" });
2026-08-22 09:36:15 +03:00
trackJob ( job . id );
2026-09-01 19:15:43 +03:00
const delivery = fakeDeliver ();
const runner = createJobRunner ({ chatStore , jobStore , deliver : delivery.deliver });
2026-09-12 02:34:18 +03:00
researchModelOverride . current = fauxEngine ([
() => {
2026-09-01 19:15:43 +03:00
throw new Error ( "model exploded" );
},
2026-09-12 02:34:18 +03:00
]);
2026-09-01 19:15:43 +03:00
try {
await runner . runJob ( job . id );
} finally {
researchModelOverride . current = undefined ;
}
2026-08-22 09:36:15 +03:00
2026-08-31 20:00:12 +03:00
const errored = jobStore . getJob ( job . id ) ! ;
2026-08-22 09:36:15 +03:00
assert . equal ( errored . status , "error" );
assert . match ( errored . error ?? "" , /model exploded/ );
2026-09-01 19:15:43 +03:00
const steer = delivery . steers [ 0 ] ! ;
assert . ok ( steer , "failure still delivers a steer" );
const text = steer . parts [ 0 ];
assert . ok ( text . type === "text" && text . text . includes ( "Research failed: model exploded" ), "failure brief on the steer text" );
const data = steer . parts [ 1 ];
assert . ok ( data . type === "data-subagent-artifact" );
assert . deepEqual (( data . data . result as ResearchResult ). sources , []);
});
it ( "runJob resolves the input from the transcript when dispatch-time input is absent (crash recovery)" , async () => {
const restoreFetch = stubFetch ();
try {
const chat = chatStore . createChat ( "Test: transcript input fallback" );
trackChat ( chat . id );
chatStore . saveMessages ( chat . id , [
{ id : "u1" , role : "user" , parts : [{ type : "text" , text : "go" }] },
{
id : "a1" ,
role : "assistant" ,
parts : [{ type : "tool-research" , toolCallId : "call-fb1" , state : "output-available" , input : RESEARCH_INPUT , output : "Dispatched." }],
},
] as never );
const job = jobStore . createJob ({ kind : "research" , chatId : chat.id , toolCallId : "call-fb1" });
trackJob ( job . id );
const delivery = fakeDeliver ();
const runner = createJobRunner ({ chatStore , jobStore , deliver : delivery.deliver });
researchModelOverride . current = researcherModel ();
try {
// no input passed — as a recovery re-spawn would call it
await runner . runJob ( job . id );
} finally {
researchModelOverride . current = undefined ;
}
const done = jobStore . getJob ( job . id ) ! ;
assert . equal ( done . status , "done" , "the researcher ran from the transcript-resolved input" );
assert . equal (( done . result as ResearchResult ). brief , BRIEF );
} finally {
restoreFetch ();
}
2026-08-22 09:36:15 +03:00
});
});
// ─── the researcher agent construction (model resolution seam) ──────────────
describe ( "researcher agent" , () => {
2026-09-12 02:34:18 +03:00
it ( "runs with the web tool set (no network touched)" , () => {
// The researcher is a runSubagent call over the webTools registry —
// the tool set is what the loop sees.
assert . ok ( webTools . web_search , "web_search available" );
assert . ok ( webTools . web_fetch , "web_fetch available" );
assert . equal ( Object . keys ( webTools ). length , 2 , "ONLY the web tools — no question/plan tools" );
2026-08-22 09:36:15 +03:00
// reset any accidental global override
researchModelOverride . current = undefined ;
});
});
2026-09-01 19:15:43 +03:00
// ─── 6. the wake pipeline (server-side steer delivery) ────────────────────────
/**
* Pipeline tests drive deliverJobResult with an injected fake turn runner
* (setTurnRunner) — the real wiring (chat-handler's handleChatRequest
* adapter) never fires here, so no teacher model is touched.
*
* Timing: the pump polls at 400ms with a 1.5s grace after the gates open —
* each happy-path test takes ~2s; waitFor tolerates it.
*/
describe ( "wake pipeline (src/server/jobs.ts)" , () => {
/** A chat with a finished job ready for delivery. */
function setup ( kind : "research" | "demo" = "research" ) : { chatId : string ; jobId : string ; steer : SubagentSteerMessage } {
const chat = chatStore . createChat ( "Test: wake pipeline" );
trackChat ( chat . id );
chatStore . saveMessages ( chat . id , [{ id : "u1" , role : "user" , parts : [{ type : "text" , text : "hello" }] }] as never );
const job = jobStore . createJob ({ kind , chatId : chat.id , toolCallId : `call- ${ jobStore . listJobs ( chat . id ). length } ` });
trackJob ( job . id );
const result : ResearchResult = { brief : "Done: it works." , sources : [{ title : "s" , url : "https://x.test" }] };
jobStore . updateJob ( job . id , { result , activity : "delivering result" });
const steer = buildSubagentSteer ( job , result );
return { chatId : chat.id , jobId : job.id , steer };
}
2026-09-11 06:44:20 +03:00
it ( "delivers a PROBE HANDOFF steer the same way — the teacher's wake turn" , async () => {
const chat = chatStore . createChat ( "Test: probe handoff wake" );
trackChat ( chat . id );
// The concluded probe: a persisted probe_done part (the seam the
// chat-handler's construction branch keys on) and no open question.
chatStore . saveMessages ( chat . id , [
{ id : "u1" , role : "user" , parts : [{ type : "text" , text : "teach me calc" }] },
{
id : "a1" ,
role : "assistant" ,
parts : [
{ type : "tool-probe_done" , state : "output-available" , output : { ok : true } },
],
},
] as never );
const handoff = buildProbeHandoffSteer ( chat . id );
let seen : { chatId : string ; lastMessage : ProbeHandoffSteerMessage } | null = null ;
setTurnRunner ( async ( cid , messages ) => {
seen = { chatId : cid , lastMessage : messages [ messages . length - 1 ] as ProbeHandoffSteerMessage };
return 200 ;
});
await deliverJobResult ( handoff );
await waitFor (() => seen !== null , 8000 , "handoff wake fired" );
assert . equal ( seen ! . chatId , chat . id );
assert . equal ( seen ! . lastMessage . id , `steer_probe_ ${ chat . id } ` , "the handoff is the wake turn's final input message" );
});
it ( "a redelivered handoff dedupes via the transcript (idempotent by steer id)" , async () => {
const chat = chatStore . createChat ( "Test: handoff idempotent" );
trackChat ( chat . id );
const handoff = buildProbeHandoffSteer ( chat . id );
// The transcript already carries the steer (a previous wake completed).
chatStore . saveMessages ( chat . id , [ handoff as never ]);
let fired = 0 ;
setTurnRunner ( async () => {
fired ++ ;
return 200 ;
});
await deliverJobResult ( handoff );
assert . equal ( fired , 0 , "already delivered — the pump resolves without firing a wake" );
});
2026-09-01 19:15:43 +03:00
it ( "delivers: fires the turn runner with the record + steer, then flips the job done" , async () => {
const { chatId , jobId , steer } = setup ();
let seen : { chatId : string ; lastMessage : unknown } | null = null ;
setTurnRunner ( async ( cid , messages ) => {
seen = { chatId : cid , lastMessage : messages [ messages . length - 1 ] };
return 200 ;
});
await deliverJobResult ( steer );
await waitFor (() => jobStore . getJob ( jobId ) ! . status !== "running" , 8000 , "job flip after delivery" );
assert . equal ( jobStore . getJob ( jobId ) ! . status , "done" );
assert . equal ( jobStore . getJob ( jobId ) ! . activity , "delivered" );
assert . ok ( seen , "the turn runner fired" );
assert . equal (( seen as { chatId : string }). chatId , chatId );
const last = ( seen as { lastMessage : SubagentSteerMessage }). lastMessage ;
assert . equal ( last . id , steer . id , "the steer is the wake turn's final input message" );
});
it ( "waits while a question part is pending, and fires once it clears" , async () => {
const { chatId , jobId , steer } = setup ();
let fired = 0 ;
setTurnRunner ( async () => {
fired ++ ;
return 200 ;
});
// An unanswered quiz part blocks the wake (provider conversion would
// break on an input-available tool call).
chatStore . saveMessages ( chatId , [
{ id : "u1" , role : "user" , parts : [{ type : "text" , text : "hello" }] },
{
id : "a1" ,
role : "assistant" ,
parts : [{ type : "tool-quiz" , toolCallId : "quiz-call" , state : "input-available" , input : { question : "q" , choices : [ "a" , "b" ] } }],
},
] as never );
// The delivery promise floats while the quiz blocks — awaiting it now
// would deadlock the test (the pump defers until the gate opens).
const delivery = deliverJobResult ( steer );
await delay ( 1200 );
assert . equal ( fired , 0 , "no wake while the quiz is pending" );
assert . equal ( jobStore . getJob ( jobId ) ! . status , "running" , "job stays running while blocked" );
// The learner answers: the pending part clears (client adds output).
const record = chatStore . getChat ( chatId ) ! ;
const answered = record . messages . map (( m ) =>
m . id === "a1"
? {
... m ,
parts : [{ type : "tool-quiz" , toolCallId : "quiz-call" , state : "output-available" , input : { question : "q" , choices : [ "a" , "b" ] }, output : { answer : "a" , correct : true , explanation : "" } }],
}
: m ,
);
chatStore . saveMessages ( chatId , answered as never );
await waitFor (() => fired > 0 , 8000 , "wake after the quiz answer" );
await delivery ; // now it resolves — the steer was delivered
await waitFor (() => jobStore . getJob ( jobId ) ! . status !== "running" , 8000 , "job flip after the deferred wake" );
assert . equal ( jobStore . getJob ( jobId ) ! . status , "done" );
});
it ( "retries with backoff on a 409 (a client turn raced in) and delivers on the retry" , async () => {
const { jobId , steer } = setup ();
let calls = 0 ;
setTurnRunner ( async () => {
calls ++ ;
return calls === 1 ? 409 : 200 ;
});
await deliverJobResult ( steer );
assert . ok ( calls >= 2 , "409 was retried" );
await waitFor (() => jobStore . getJob ( jobId ) ! . status !== "running" , 8000 , "job flip after the retry" );
assert . equal ( jobStore . getJob ( jobId ) ! . status , "done" );
});
it ( "is idempotent: a steer already in the transcript flips the job without firing a turn" , async () => {
const { chatId , jobId , steer } = setup ();
// Simulate the crash window: the wake completed but the flip didn't.
const record = chatStore . getChat ( chatId ) ! ;
chatStore . saveMessages ( chatId , [... record . messages , steer ] as never );
let fired = 0 ;
setTurnRunner ( async () => {
fired ++ ;
return 200 ;
});
await deliverJobResult ( steer );
assert . equal ( fired , 0 , "no duplicate wake for an already-delivered steer" );
assert . equal ( jobStore . getJob ( jobId ) ! . status , "done" , "the job still flips — recovery completes" );
});
2026-09-09 03:04:21 +03:00
// ─── continuation coalescing (the delivery-starvation fix) ───────────────────
describe ( "claimSteers + pump duplicate-guard (continuation coalescing)" , () => {
it ( "claims queued undelivered steers: resolves deliver(), flips the job done, empties the queue" , async () => {
const { chatId , jobId , steer } = setup ();
let fired = 0 ;
setTurnRunner ( async () => {
fired ++ ;
return 200 ;
});
const delivery = deliverJobResult ( steer );
await delay ( 300 ); // the pump is inside waitForIdle's grace (750ms)
const claimed = claimSteers ( chatId );
assert . equal ( claimed . length , 1 , "the queued steer is claimed" );
assert . equal ( claimed [ 0 ] ! . id , steer . id );
// The handler's write-ahead persist lands the claim immediately after
// claiming (the very next statement in handleChatRequest).
const record = chatStore . getChat ( chatId ) ! ;
chatStore . saveMessages ( chatId , [... record . messages , ... claimed ] as never );
await delivery ; // the runner's deliver() promise resolves on claim
assert . equal ( jobStore . getJob ( jobId ) ! . status , "done" , "job flips done at claim" );
assert . deepEqual ( claimSteers ( chatId ), [], "queue is empty after the claim" );
await delay ( 800 );
assert . equal ( fired , 0 , "the pump never fires a wake for a claimed steer" );
});
it ( "finalizes already-delivered steers without returning them (a wake raced in)" , async () => {
const { chatId , jobId , steer } = setup ();
const record = chatStore . getChat ( chatId ) ! ;
chatStore . saveMessages ( chatId , [... record . messages , steer ] as never );
const delivery = deliverJobResult ( steer );
await delay ( 100 ); // let the pump see it first (its top-of-loop idempotent skip)
const claimed = claimSteers ( chatId );
assert . equal ( claimed . length , 0 , "delivered steers are not re-claimed" );
assert . equal ( jobStore . getJob ( jobId ) ! . status , "done" );
await delivery ;
});
it ( "claimSteers on a chat with no queue returns [] and touches nothing" , () => {
assert . deepEqual ( claimSteers ( "chat_never_existed" ), []);
});
it ( "pump duplicate-guard: a steer coalesced into the record while the gates were closed is never double-fired" , async () => {
const { chatId , jobId , steer } = setup ();
let fired = 0 ;
setTurnRunner ( async () => {
fired ++ ;
return 200 ;
});
// Gates closed: a pending quiz parks the pump in waitForIdle.
chatStore . saveMessages ( chatId , [
{ id : "u1" , role : "user" , parts : [{ type : "text" , text : "hello" }] },
{
id : "a1" ,
role : "assistant" ,
parts : [{ type : "tool-quiz" , toolCallId : "quiz-call" , state : "input-available" , input : { question : "q" , choices : [ "a" , "b" ] } }],
},
] as never );
const delivery = deliverJobResult ( steer );
await delay ( 1200 );
assert . equal ( fired , 0 , "parked behind the pending quiz" );
// The client turn coalesces: it claims the steer and lands it in the record.
const claimed = claimSteers ( chatId );
assert . equal ( claimed . length , 1 , "coalescing claims the parked steer" );
const record = chatStore . getChat ( chatId ) ! ;
chatStore . saveMessages ( chatId , [... record . messages , steer ] as never ); // write-ahead persist
// The learner answers: the gates open — the parked pump must see the
// delivered steer via its post-idle re-check, NOT fire a second turn.
const answered = chatStore . getChat ( chatId ) ! . messages . map (( m ) =>
m . id === "a1"
? {
... m ,
parts : [{ type : "tool-quiz" , toolCallId : "quiz-call" , state : "output-available" , input : { question : "q" , choices : [ "a" , "b" ] }, output : { answer : "a" , correct : true , explanation : "" } }],
}
: m ,
);
chatStore . saveMessages ( chatId , answered as never );
await delivery ;
await delay ( 1500 );
assert . equal ( fired , 0 , "no duplicate wake after coalescing" );
assert . equal ( jobStore . getJob ( jobId ) ! . status , "done" );
});
});
2026-09-01 19:15:43 +03:00
it ( "fails the job (error status) when the wake turn errors unrecoverably" , async () => {
const { jobId , steer } = setup ();
setTurnRunner ( async () => 500 );
await deliverJobResult ( steer ). then (
() => assert . fail ( "expected delivery to reject" ),
() => undefined ,
);
const errored = jobStore . getJob ( jobId ) ! ;
assert . equal ( errored . status , "error" );
assert . match ( errored . error ?? "" , /status 500/ );
});
});
function delay ( ms : number ) : Promise < void > {
return new Promise (( resolve ) => setTimeout ( resolve , ms ));
}
2026-09-02 02:34:02 +03:00
// ─── persist id hygiene (the duplicate-empty-key bug) ────────────────────────
describe ( "persistTurn id hygiene" , () => {
it ( "withMessageIds fills EMPTY ids (server-only synthesized messages) and preserves existing ones" , () => {
const filled = withMessageIds ([
{ id : "" , role : "assistant" , parts : [{ type : "text" , text : "wake turn" }] },
{ id : "client-id" , role : "user" , parts : [{ type : "text" , text : "hi" }] },
]);
assert . notEqual ( filled [ 0 ] ! . id , "" , "empty id replaced" );
assert . match ( filled [ 0 ] ! . id , /^msg_/ );
assert . equal ( filled [ 1 ] ! . id , "client-id" , "client-authored id untouched" );
assert . notEqual ( filled [ 0 ] ! . id , filled [ 1 ] ! . id );
});
2026-09-09 05:43:28 +03:00
it ( "persistTurn dedupes same-id continuation copies, keeping the LAST (the SDK's merged superset)" , () => {
// The coalescing shape: a steer (user-role) rides a quiz-answer
// continuation AFTER the assistant message being continued, so the
// SDK's onFinish list carries the input copy AND the extended
// response copy under one id — the record must keep only the merge.
const chat = chatStore . createChat ( "Test: dedupe continuation copies" );
trackChat ( chat . id );
2026-09-09 05:54:04 +03:00
const short : UIMessage = { id : "msg_dup_1" , role : "assistant" , parts : [{ type : "text" , text : "step 1" }] };
const long : UIMessage = { id : "msg_dup_1" , role : "assistant" , parts : [{ type : "text" , text : "step 1" }, { type : "text" , text : "step 2 (continued)" }] };
2026-09-09 05:43:28 +03:00
persistTurn ( chatStore , chat . id , [
{ id : "u1" , role : "user" , parts : [{ type : "text" , text : "hi" }] },
short ,
{ id : "steer_x" , role : "user" , parts : [{ type : "text" , text : "research complete" }] },
long ,
], false );
const record = chatStore . getChat ( chat . id ) ! ;
assert . equal ( record . messages . length , 3 , "the duplicate entry is dropped" );
assert . deepEqual (
record . messages . map (( m ) => m . id ),
[ "u1" , "steer_x" , "msg_dup_1" ],
"order preserved, steer before the merged assistant copy" ,
);
const merged = record . messages [ 2 ] ! ;
assert . equal ( merged . parts . length , 2 , "the KEPT copy is the extended one" );
assert . equal (( merged . parts [ 1 ] as { text : string }). text , "step 2 (continued)" );
});
2026-09-02 02:34:02 +03:00
it ( "persistTurn saves every message with a unique non-empty id (wake turns never re-POSTed)" , () => {
const chat = chatStore . createChat ( "Test: persist ids" );
trackChat ( chat . id );
persistTurn ( chatStore , chat . id , [
{ id : "" , role : "user" , parts : [{ type : "text" , text : "q" }] },
{ id : "" , role : "assistant" , parts : [{ type : "text" , text : "a1" }] },
{ id : "" , role : "assistant" , parts : [{ type : "text" , text : "a2" }] },
], false );
const record = chatStore . getChat ( chat . id );
const ids = record ! . messages . map (( m ) => m . id );
assert . ok ( ids . every (( id ) => id && id . length > 0 ), "no empty ids survive persist" );
assert . equal ( new Set ( ids ). size , ids . length , "ids unique — React keys safe" );
});
});
2026-09-02 03:19:03 +03:00
// ─── prompt composition hygiene (the README leak) ─────────────────────────────
describe ( "prompt composition" , () => {
it ( "loads ONLY numeric-prefix fragments — README.md never enters the system prompt" , () => {
2026-09-11 06:44:20 +03:00
const instructions = composeInstructions ( "teacher" );
2026-09-02 03:19:03 +03:00
assert . ok ( ! instructions . includes ( "Prompt fragments" ), "README content must not leak" );
assert . ok ( ! instructions . includes ( "Rules for adding a fragment" ), "README content must not leak (rules)" );
// every numeric fragment DOES compose, verbatim, after the base prompt
const base = fs . readFileSync ( "src/agent/system-prompt.md" , "utf-8" ). trim ();
assert . ok ( instructions . startsWith ( base ), "base prompt first" );
for ( const f of fs . readdirSync ( "src/agent/prompts" ). filter (( f ) => /^\d+-[\w.-]+\.md$/ . test ( f )). sort ()) {
const body = fs . readFileSync ( `src/agent/prompts/ ${ f } ` , "utf-8" ). trim ();
2026-09-05 21:33:13 +03:00
assert . ok ( instructions . includes ( body ), `fragment ${ f } must compose verbatim` );
2026-09-02 03:19:03 +03:00
}
});
2026-09-05 21:33:13 +03:00
it ( "guards prompt size — no fragment can silently balloon (the README-leak class)" , () => {
// The wave-1 leak: README.md (815 bytes / ~200 tokens) rode into every
// teacher turn until the numeric-prefix filter stopped it. This guard
// catches the sibling failure mode — an accidental dump pasted into a
// fragment. Thresholds: largest real fragment is 40-demos.md at ~3.2k
// chars; 8k is generous headroom. Composed total ≤ 60k chars.
for ( const f of fs . readdirSync ( "src/agent/prompts" ). filter (( f ) => /^\d+-[\w.-]+\.md$/ . test ( f ))) {
const body = fs . readFileSync ( `src/agent/prompts/ ${ f } ` , "utf-8" ). trim ();
assert . ok ( body . length <= 8 _000 , `fragment ${ f } is ${ body . length } chars — over the 8k guard` );
}
2026-09-11 06:44:20 +03:00
assert . ok ( composeInstructions ( "teacher" ). length <= 60 _000 , "composed system prompt over the 60k guard" );
2026-09-05 21:33:13 +03:00
});
2026-09-02 03:19:03 +03:00
});
2026-09-11 03:25:09 +03:00
// ─── abort (2026-09 semantics: real kill, cancelled status, teacher told) ───
describe ( "abort (src/server/jobs.ts abortJob)" , () => {
2026-09-12 02:34:18 +03:00
/** A hanging model whose call never resolves until its abort signal
* fires — the real-kill path: the in-flight call itself is killed. */
function hangingModel () : PiEngine {
return fauxEngine ([ abortableFactory ()]);
2026-09-11 03:25:09 +03:00
}
2026-09-12 02:34:18 +03:00
/** One step (billed) then a hang — proves usage-so-far survives the kill. */
function oneStepThenHangModel () : PiEngine {
return fauxEngine ([
fauxAssistantMessage ([ fauxToolCall ( "web_search" , { query : "anything" }, { id : "ws1" })], {
stopReason : "toolUse" ,
}),
abortableFactory (),
]);
2026-09-11 03:25:09 +03:00
}
function newChatWithResearchJob ( label : string , toolCallId : string ) : { chatId : string ; jobId : string } {
const chat = chatStore . createChat ( `Test: ${ label } ` );
trackChat ( chat . id );
chatStore . saveMessages ( chat . id , [
{ id : "u1" , role : "user" , parts : [{ type : "text" , text : "hi" }] },
{
id : "a1" ,
role : "assistant" ,
parts : [
{ type : "text" , text : "Checking." },
{ type : "tool-research" , toolCallId , state : "output-available" , input : RESEARCH_INPUT , output : "Dispatched." },
],
},
] as never );
const job = jobStore . createJob ({ kind : "research" , chatId : chat.id , toolCallId });
trackJob ( job . id );
return { chatId : chat.id , jobId : job.id };
}
it ( "kills the running loop: error \"Aborted by learner\" immediately, loop unwinds silently, teacher gets the abort steer" , async () => {
const { jobId } = newChatWithResearchJob ( "abort running job" , "call-abort1" );
const fired : SteerMessage [] = [];
setTurnRunner ( async ( _cid , messages ) => {
fired . push ( messages [ messages . length - 1 ] as SteerMessage );
return 200 ;
});
researchModelOverride . current = hangingModel ();
const runner = createJobRunner ({
chatStore ,
jobStore ,
deliver : async () => {
throw new Error ( "a cancelled job must never deliver a result steer" );
},
});
try {
const settled = runner . runJob ( jobId , RESEARCH_INPUT );
await waitFor (() => jobStore . getJob ( jobId ) ! . activity === "researching" , 8000 , "loop started" );
const outcome = abortJob ( jobId );
assert . ok ( outcome . ok );
const killed = jobStore . getJob ( jobId ) ! ;
assert . equal ( killed . status , "error" , "flipped synchronously — the poll sees it at once" );
assert . equal ( killed . error , "Aborted by learner" , "abort reads as an error with a reason, not a failure" );
assert . equal ( killed . activity , "aborted" );
await settled ; // unwinds — no hang
assert . equal ( jobStore . getJob ( jobId ) ! . status , "error" , "the runner never overwrites the flip" );
assert . equal ( jobStore . getJob ( jobId ) ! . error , "Aborted by learner" );
assert . equal ( jobStore . getJob ( jobId ) ! . result , undefined , "no result on an aborted job" );
await waitFor (() => fired . length > 0 , 8000 , "abort wake fired" );
const last = fired [ 0 ] ! ;
2026-09-11 06:44:20 +03:00
assert . equal (( last . metadata as { subagentResult : { aborted : boolean } }). subagentResult . aborted , true );
2026-09-11 03:25:09 +03:00
assert . equal ( last . id , `steer_ ${ jobId } ` );
2026-09-12 05:14:40 +03:00
assert . equal ( last . parts . length , 2 , "teacher-facing text + the data-subagent-aborted marker part — no artifact" );
assert . equal ( last . parts [ 1 ] ! . type , "data-subagent-aborted" );
2026-09-11 03:25:09 +03:00
// aborted is terminal: the wake completing must not flip it
assert . equal ( jobStore . getJob ( jobId ) ! . status , "error" , "terminal after the wake" );
} finally {
researchModelOverride . current = undefined ;
}
});
it ( "the usage folded so far survives the kill (money channel outlives the abort)" , async () => {
const restoreFetch = stubFetch ();
try {
const { jobId } = newChatWithResearchJob ( "abort keeps usage" , "call-abort2" );
setTurnRunner ( async () => 200 );
researchModelOverride . current = oneStepThenHangModel ();
const runner = createJobRunner ({ chatStore , jobStore , deliver : async () => {} });
try {
const settled = runner . runJob ( jobId , RESEARCH_INPUT );
await waitFor (() => ( jobStore . getJob ( jobId ) ! . usage ? . steps ?? 0 ) === 1 , 8000 , "first step folded" );
abortJob ( jobId );
await settled ;
const job = jobStore . getJob ( jobId ) ! ;
assert . equal ( job . status , "error" );
assert . equal ( job . error , "Aborted by learner" );
assert . equal ( job . usage ! . steps , 1 , "usage-so-far persisted by the runner's finally" );
assert . equal ( job . usage ! . model , "z-ai/glm-5.3-flash" , "the config role's OpenRouter id" );
2026-09-12 02:34:18 +03:00
// faux estimates tokens from the prompt/response text — the
// pinned fact is that the ONE completed step's spend survived.
assert . ok ( job . usage ! . inputTokens ! > 0 , "input tokens billed" );
assert . ok ( job . usage ! . outputTokens ! > 0 , "output tokens billed" );
2026-09-11 03:25:09 +03:00
} finally {
researchModelOverride . current = undefined ;
}
} finally {
restoreFetch ();
}
});
it ( "aborting a delivering job drops the queued result steer and wakes with the abort note instead" , async () => {
const chat = chatStore . createChat ( "Test: abort delivering job" );
trackChat ( chat . id );
chatStore . saveMessages ( chat . id , [{ id : "u1" , role : "user" , parts : [{ type : "text" , text : "hello" }] }] as never );
const job = jobStore . createJob ({ kind : "research" , chatId : chat.id , toolCallId : "call-abort3" });
trackJob ( job . id );
const result : ResearchResult = { brief : "Done: it works." , sources : [{ title : "s" , url : "https://x.test" }] };
jobStore . updateJob ( job . id , { result , activity : "delivering result" });
// Block the pump's idle gates so the result steer sits QUEUED, not in flight.
assert . ok ( beginTurn ( chat . id ));
const fired : SteerMessage [] = [];
setTurnRunner ( async ( _cid , messages ) => {
fired . push ( messages [ messages . length - 1 ] as SteerMessage );
return 200 ;
});
void deliverJobResult ( buildSubagentSteer ( job , result )). catch (() => {
/* dropped by the abort below */
});
await waitFor (() => jobStore . getJob ( job . id ) ! . activity === "delivering result" , 8000 , "queued" );
const outcome = abortJob ( job . id );
assert . ok ( outcome . ok );
assert . equal ( jobStore . getJob ( job . id ) ! . status , "error" );
assert . equal ( jobStore . getJob ( job . id ) ! . error , "Aborted by learner" );
endTurn ( chat . id ); // gates open — whatever is left in the queue fires
await waitFor (() => fired . length > 0 , 8000 , "wake fired" );
assert . equal ( fired . length , 1 , "exactly one delivery — the result steer was dropped" );
2026-09-11 06:44:20 +03:00
assert . equal (
( fired [ 0 ] ! . metadata as { subagentResult : { aborted : boolean } }). subagentResult . aborted ,
true ,
"the abort steer, not the result" ,
);
2026-09-11 03:25:09 +03:00
assert . equal ( jobStore . getJob ( job . id ) ! . status , "error" , "terminal after the wake" );
});
it ( "outcome reasons: not_found for unknown, not_running for settled jobs" , () => {
assert . deepEqual ( abortJob ( "job_does_not_exist" ), { ok : false , reason : "not_found" });
const chat = chatStore . createChat ( "Test: abort settled job" );
trackChat ( chat . id );
const job = jobStore . createJob ({ kind : "research" , chatId : chat.id , toolCallId : "call-abort4" });
trackJob ( job . id );
jobStore . updateJob ( job . id , { status : "done" , activity : "delivered" });
assert . deepEqual ( abortJob ( job . id ), { ok : false , reason : "not_running" });
});
});