feat: abort sub-agent jobs (real kill) + runner usage capture into JobRecord.usage

Wave 4 (agent-side observability + control), reconciled with the dock
contract (master relay 2026-09):

- abortJob in src/server/jobs.ts, exported through the server barrel for
  POST /api/activity/abort (master's thin route): fires the job's
  AbortController (real kill — the in-flight model call aborts, not just
  the next loop check), drops any queued result steer, flips the record
  to error "Aborted by learner" + activity "aborted" synchronously, and
  queues a one-line abort steer (buildAbortSteer) through the same wake
  pipeline so the teacher's promise doesn't hang in the lesson. Shares
  the result steer's deterministic id space, so abort + racing result
  can never both deliver (steerDelivered dedupe).
- Abort registry (src/agent/subagents/aborts.ts) on globalThis, mirroring
  the wake pipeline's module-state pattern; runner registers/unregisters
  per run, so the route's module instance sees the loop's controller.
- Runner usage capture: LoopContext (abortSignal + onUsage) threads into
  all three kind loops; per-step usage folds via accumulateJobUsage into
  JobRecord.usage (model from config MODELS, absent-never-zero), flushed
  in runJob's finally so an aborted job's money record survives the kill.
- Contracts: JobUsage token fields optional + lastStep (most recent
  call's accounting, for the dock's per-blob context line vs
  CONTEXT_LIMITS); SubagentResultMeta.aborted; AbortSteerMessage;
  buildAbortSteer; SteerMessage union generalized.
- demo-builder/visual-maker accept abortSignal + onUsage (visual folds
  generateText usage per iteration).
- stillRunning/finishJob/jobContext guards: an aborted job is never
  overwritten by the runner (terminal no matter what the wake does);
  failJob and the pump's finalizeJob keep the flip.
- UI: abort steer renders a quiet aborted chip (job-aborted-badge).
- Prompt 20-jobs.md: one line about aborted jobs.

Tests: accumulateJobUsage folds (undefined-preservation, lastStep),
runner usage capture, real-kill abort (sync flip, silent unwind,
abort steer wakes), usage-so-far survives the kill, aborting a
delivering job drops the queued result steer, outcome reasons,
abort-chip render. 330/330, typecheck/lint/build green.
This commit is contained in:
amosblomqvist
2026-09-11 03:25:09 +03:00
parent 83a00800e9
commit 17ed37a471
10 changed files with 644 additions and 33 deletions
+21 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { CheckIcon } from "lucide-react"; import { CheckIcon, CircleX } from "lucide-react";
import type { ResearchResult, SubagentArtifactData, TeacherUIMessage } from "@/src/core"; import type { ResearchResult, SubagentArtifactData, TeacherUIMessage } from "@/src/core";
import { isSubagentFailure } from "@/src/core"; import { isSubagentFailure } from "@/src/core";
import { DemoOutputCard } from "@/components/artifacts/demo-card"; import { DemoOutputCard } from "@/components/artifacts/demo-card";
@@ -27,9 +27,15 @@ import { VisualOutputCard } from "@/components/artifacts/visual-card";
export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) { export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact"); const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
const data = artifactPart?.data as SubagentArtifactData | undefined; const data = artifactPart?.data as SubagentArtifactData | undefined;
// The abort steer (2026-09 dock semantics): no artifact part, only the
// metadata marker — a quiet chip, mirroring the complete-badge idiom.
if (!data) { if (!data) {
const meta = (
message.metadata as { subagentResult?: { aborted?: boolean; kind?: string } } | undefined
)?.subagentResult;
if (meta?.aborted) return <AbortedBadge kind={meta.kind ?? "job"} />;
return ( return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
A sub-agent finished, but its result could not be displayed. A sub-agent finished, but its result could not be displayed.
</div> </div>
); );
@@ -48,6 +54,19 @@ export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
} }
} }
/** The aborted-job chip: mirrors the complete-badge one-liner — the
* teacher's wake turn acknowledges the abort in the lesson itself,
* this just marks what happened. */
function AbortedBadge({ kind }: { kind: string }) {
return (
<div data-testid="job-aborted-badge" className="flex items-center gap-2 text-xs text-muted-foreground">
<CircleX className="size-3.5" />
<span className="font-medium uppercase tracking-wide">{kind}</span>
<span>aborted by the learner</span>
</div>
);
}
/** Quiet one-liner, the dispatch-settled idiom — the lesson lives in the /** Quiet one-liner, the dispatch-settled idiom — the lesson lives in the
* teacher's wake turn; the badge just marks delivery. The persisted brief * teacher's wake turn; the badge just marks delivery. The persisted brief
* rides along in a collapsed <details> (the wave-1 "view full research" * rides along in a collapsed <details> (the wave-1 "view full research"
+4
View File
@@ -27,3 +27,7 @@ Dispatch as many research jobs as you genuinely need — in one turn or across t
parallel when the questions are independent — but don't fan out on one question; the parallel when the questions are independent — but don't fan out on one question; the
researcher searches multiple angles on its own. If research fails, you'll get a failure researcher searches multiple angles on its own. If research fails, you'll get a failure
message: say so plainly and continue with what you can establish, flagging the uncertainty. message: say so plainly and continue with what you can establish, flagging the uncertainty.
A job can also be **aborted by the learner** — you'll get a one-line "was aborted"
message instead of the result. Acknowledge it in one sentence and continue teaching
without it (offering to re-dispatch is fine if the learner wants the result).
+50
View File
@@ -0,0 +1,50 @@
/**
* Job abort registry — WHO can actually kill a running sub-agent.
*
* The runner creates one AbortController per running job and registers it
* here; the abort path (src/server/jobs.ts abortJob, driven by the dock's
* END button via POST /api/jobs/[jobId]/abort) fires it through fireJobAbort.
* Two module trees meet: the runner (src/agent) and the endpoint
* (src/server) — the registry lives on globalThis for the same reason as
* the turn registry and the wake pipeline: Next dev gives each route entry
* its own module instances, so a module-level Map would be a different
* object in each. Placed under src/agent (server→agent imports are an
* established direction; agent→server is not — see chat-handler).
*
* 2026-09 abort semantics (user-decided): REAL KILL, not mark-and-abandon —
* the controller aborts the in-flight model call (agent.generate /
* generateText abortSignal); headless tool runs are best-effort (the next
* model call is where a hung one dies). The route owns the bookkeeping
* (status error "Aborted by learner" + the abort steer); the runner only unwinds.
*/
interface LhAbortGlobal {
__lhJobAborts?: Map<string, AbortController>;
}
function registry(): Map<string, AbortController> {
const g = globalThis as typeof globalThis & LhAbortGlobal;
return (g.__lhJobAborts ??= new Map());
}
/** The runner registers its controller at job start (idempotent overwrite). */
export function registerJobAbort(jobId: string, controller: AbortController): void {
registry().set(jobId, controller);
}
/** The runner unregisters in its finally block (job settled or unwound). */
export function unregisterJobAbort(jobId: string): void {
registry().delete(jobId);
}
/** Abort a running job's loop. Returns false when no controller is live —
* the job's loop is not executing (crashed, or in the delivering-result
* phase where the runner has already returned); the caller then owns the
* whole abort on the record side. */
export function fireJobAbort(jobId: string): boolean {
const controller = registry().get(jobId);
if (!controller) return false;
registry().delete(jobId);
controller.abort();
return true;
}
+7 -1
View File
@@ -1,7 +1,7 @@
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type ToolSet } from "ai"; import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type ToolSet } from "ai";
import { z } from "zod"; import { z } from "zod";
import { modelFor } from "../models.ts"; import { modelFor } from "../models.ts";
import type { DemoResult } from "../../core/contracts.ts"; import type { DemoResult, UsageStepLike } from "../../core/contracts.ts";
import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts"; import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts";
/** /**
@@ -32,6 +32,10 @@ export interface DemoBuilderOptions {
maxSteps?: number; maxSteps?: number;
/** Live activity-line callback (updates the job record). */ /** Live activity-line callback (updates the job record). */
onActivity?: (line: string) => void; onActivity?: (line: string) => void;
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
abortSignal?: AbortSignal;
/** Money channel: one call per agent step, folded into JobRecord.usage. */
onUsage?: (usage: UsageStepLike) => void;
} }
const INSTRUCTIONS = `You are a demo builder — a specialist sub-agent inside a learning application. You const INSTRUCTIONS = `You are a demo builder — a specialist sub-agent inside a learning application. You
@@ -189,6 +193,8 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
try { try {
const result = await agent.generate({ const result = await agent.generate({
prompt, prompt,
abortSignal: opts.abortSignal,
onStepFinish: ({ usage }) => opts.onUsage?.(usage),
onToolExecutionStart: ({ toolCall }) => { onToolExecutionStart: ({ toolCall }) => {
opts.onActivity?.(toolCall.toolName); opts.onActivity?.(toolCall.toolName);
}, },
+117 -13
View File
@@ -1,16 +1,21 @@
import { isToolUIPart, type LanguageModel, type UIMessage } from "ai"; import { isToolUIPart, type LanguageModel, type UIMessage } from "ai";
import { import {
accumulateJobUsage,
buildSubagentSteer, buildSubagentSteer,
type DemoResult, type DemoResult,
type DemoToolInput, type DemoToolInput,
type JobKind, type JobKind,
type JobRecord, type JobRecord,
type JobUsage,
type ResearchResult, type ResearchResult,
type ResearchToolInput, type ResearchToolInput,
type SubagentSteerMessage, type SubagentSteerMessage,
type UsageStepLike,
type VisualResult, type VisualResult,
type VisualToolInput, type VisualToolInput,
} from "../../core/contracts.ts"; } from "../../core/contracts.ts";
import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
import { MODELS } from "../../../config.ts";
import type { ChatStore, JobStore } from "../../core/ports.ts"; import type { ChatStore, JobStore } from "../../core/ports.ts";
import { runDemoBuilder } from "./demo-builder.ts"; import { runDemoBuilder } from "./demo-builder.ts";
import { createResearcherAgent } from "./researcher.ts"; import { createResearcherAgent } from "./researcher.ts";
@@ -67,16 +72,70 @@ export interface JobRunner {
runJob(jobId: string, input?: ResearchToolInput | DemoToolInput | VisualToolInput): Promise<void>; runJob(jobId: string, input?: ResearchToolInput | DemoToolInput | VisualToolInput): Promise<void>;
} }
/** One job loop's injected machinery: the kill switch (real abort, 2026-09
* semantics) and the usage tap (money channel — every model call folds into
* JobRecord.usage as it completes). */
interface LoopContext {
/** Fires when abortJob kills the run; abortSignal is passed into every
* model call so the spend stops mid-call, not at the next loop check. */
abortSignal: AbortSignal;
/** Fold one model call's usage into the job record (progress write). */
onUsage: (step: UsageStepLike) => void;
}
/** The OpenRouter model id for a job kind — JobUsage.model. The kind→role
* table mirrors MODELS in config.ts (research→researcher, demo→demoBuilder,
* visual→visualMaker); the id is taken from config so a price-table sync
* re-prices jobs for free. Model overrides (tests) still bill under the
* configured model — usage is what is being captured, not which instance. */
const KIND_MODEL: Record<JobKind, string> = {
research: MODELS.researcher.model,
demo: MODELS.demoBuilder.model,
visual: MODELS.visualMaker.model,
};
export function createJobRunner(deps: JobRunnerDeps): JobRunner { export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const { chatStore, jobStore, deliver } = deps; const { chatStore, jobStore, deliver } = deps;
/** Running usage totals per live job (the loop's onUsage folds here,
* then writes through the job store so the poll sees progress). */
const usageTotals = new Map<string, JobUsage>();
function makeLoopContext(jobId: string, kind: JobKind): LoopContext {
const model = KIND_MODEL[kind];
// Real-kill wiring (2026-09 abort semantics): the controller is what
// the abort endpoint fires (agent/subagents/aborts.ts registry); the
// signal rides into every model call of the loop.
const controller = new AbortController();
registerJobAbort(jobId, controller);
return {
abortSignal: controller.signal,
onUsage: (step) => {
const next = accumulateJobUsage(usageTotals.get(jobId), model, step);
usageTotals.set(jobId, next);
jobStore.updateJob(jobId, { usage: next }); // merge patch — status untouched
},
};
}
/** True while the job may still write terminal state (result, error,
* steer). False once abortJob has flipped it to error ("Aborted by learner") —
* the runner then unwinds silently; the abort path owns the record and
* the abort steer. */
function stillRunning(jobId: string): boolean {
return jobStore.getJob(jobId)?.status === "running";
}
/** The kind→runner map: one sub-agent loop per job kind. */ /** The kind→runner map: one sub-agent loop per job kind. */
// Each kind's runner narrows the union input itself (dispatch-time inputs // Each kind's runner narrows the union input itself (dispatch-time inputs
// are kind-correct by construction; transcript fallbacks are cast after). // are kind-correct by construction; transcript fallbacks are cast after).
const KIND_RUNNERS: Record<JobKind, (jobId: string, input?: JobToolInput) => Promise<void>> = { const KIND_RUNNERS: Record<
research: (jobId, input) => runResearchJob(jobId, researchModelOverride.current, input), JobKind,
demo: (jobId, input) => runDemoJob(jobId, demoModelOverride.current, input), (jobId: string, input: JobToolInput | undefined, loop: LoopContext) => Promise<void>
visual: (jobId, input) => runVisualJob(jobId, visualModelOverride.current, input), > = {
research: (jobId, input, loop) => runResearchJob(jobId, researchModelOverride.current, input, loop),
demo: (jobId, input, loop) => runDemoJob(jobId, demoModelOverride.current, input, loop),
visual: (jobId, input, loop) => runVisualJob(jobId, visualModelOverride.current, input, loop),
}; };
async function runJob(jobId: string, input?: JobToolInput): Promise<void> { async function runJob(jobId: string, input?: JobToolInput): Promise<void> {
@@ -92,7 +151,17 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
jobStore.updateJob(jobId, { status: "error", error: `unknown kind ${job.kind}`, activity: "failed" }); jobStore.updateJob(jobId, { status: "error", error: `unknown kind ${job.kind}`, activity: "failed" });
return; return;
} }
await run(jobId, input); try {
await run(jobId, input, makeLoopContext(jobId, job.kind));
} finally {
unregisterJobAbort(jobId);
// The last usage write may have been mid-flight when the loop died;
// persist what was folded (the abort path patches only status, so
// this never resurrects an aborted job — merge patch).
const usage = usageTotals.get(jobId);
if (usage) jobStore.updateJob(jobId, { usage });
usageTotals.delete(jobId);
}
} }
/** Resolve the job's tool input: dispatch passes it directly; only a /** Resolve the job's tool input: dispatch passes it directly; only a
@@ -132,6 +201,10 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult): Promise<void> { async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult): Promise<void> {
const job = jobStore.getJob(jobId); const job = jobStore.getJob(jobId);
if (!job) return; if (!job) return;
// Aborted mid-run or mid-delivery (abortJob flips the record before
// the loop unwinds): no result write, no steer — the abort path owns
// the record and queues the abort steer itself.
if (job.status !== "running") return;
jobStore.updateJob(jobId, { result, activity: "delivering result" }); jobStore.updateJob(jobId, { result, activity: "delivering result" });
const steer = buildSubagentSteer(job, result); const steer = buildSubagentSteer(job, result);
try { try {
@@ -157,7 +230,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
// ─── research ─────────────────────────────────────────────────────────── // ─── research ───────────────────────────────────────────────────────────
async function runResearchJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> { async function runResearchJob(
jobId: string,
model: LanguageModel | undefined,
passedInput: JobToolInput | undefined,
loop: LoopContext,
): Promise<void> {
const ctx = jobContext(jobId); const ctx = jobContext(jobId);
if (!ctx) return; if (!ctx) return;
const { job } = ctx; const { job } = ctx;
@@ -181,6 +259,10 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
jobStore.updateJob(jobId, { activity: "researching" }); jobStore.updateJob(jobId, { activity: "researching" });
const result = await agent.generate({ const result = await agent.generate({
prompt, prompt,
abortSignal: loop.abortSignal,
// Money channel: each agent step is one model call — fold its
// usage into the record as it completes (progress write).
onStepFinish: ({ usage }) => loop.onUsage(usage),
onToolExecutionStart: ({ toolCall }) => { onToolExecutionStart: ({ toolCall }) => {
const input = toolCall.input as { query?: string; url?: string } | undefined; const input = toolCall.input as { query?: string; url?: string } | undefined;
if (toolCall.toolName === "web_fetch" && typeof input?.url === "string") { if (toolCall.toolName === "web_fetch" && typeof input?.url === "string") {
@@ -195,15 +277,24 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const research: ResearchResult = { brief, sources: parseSources(brief, fetchedUrls) }; const research: ResearchResult = { brief, sources: parseSources(brief, fetchedUrls) };
await finishJob(jobId, research); await finishJob(jobId, research);
} catch (err) { } catch (err) {
// Real-kill abort (2026-09): the abort path flipped the record and
// queued the abort steer — unwind silently, keep the usage
// folded so far (flushed by runJob's finally).
if (loop.abortSignal.aborted) return;
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
jobStore.updateJob(jobId, { error: reason }); if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
await finishJob(jobId, { brief: `Research failed: ${reason}`, sources: [] }); await finishJob(jobId, { brief: `Research failed: ${reason}`, sources: [] });
} }
} }
// ─── demo + visual ────────────────────────────────────────────────────── // ─── demo + visual ──────────────────────────────────────────────────────
async function runDemoJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> { async function runDemoJob(
jobId: string,
model: LanguageModel | undefined,
passedInput: JobToolInput | undefined,
loop: LoopContext,
): Promise<void> {
const ctx = jobContext(jobId); const ctx = jobContext(jobId);
if (!ctx) return; if (!ctx) return;
const { job } = ctx; const { job } = ctx;
@@ -218,19 +309,28 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
{ title, brief }, { title, brief },
{ {
model, model,
abortSignal: loop.abortSignal,
onUsage: loop.onUsage,
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }), onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
}, },
); );
if (isDemoFailure(result)) jobStore.updateJob(jobId, { error: result.notes ?? "demo build failed" }); if (isDemoFailure(result) && stillRunning(jobId))
jobStore.updateJob(jobId, { error: result.notes ?? "demo build failed" });
await finishJob(jobId, result); await finishJob(jobId, result);
} catch (err) { } catch (err) {
if (loop.abortSignal.aborted) return;
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
jobStore.updateJob(jobId, { error: reason }); if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
await finishJob(jobId, { title, html: "", tested: true, notes: `FAILED: ${reason}` }); await finishJob(jobId, { title, html: "", tested: true, notes: `FAILED: ${reason}` });
} }
} }
async function runVisualJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> { async function runVisualJob(
jobId: string,
model: LanguageModel | undefined,
passedInput: JobToolInput | undefined,
loop: LoopContext,
): Promise<void> {
const ctx = jobContext(jobId); const ctx = jobContext(jobId);
if (!ctx) return; if (!ctx) return;
const { job } = ctx; const { job } = ctx;
@@ -244,14 +344,18 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
{ brief }, { brief },
{ {
model, model,
abortSignal: loop.abortSignal,
onUsage: loop.onUsage,
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }), onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
}, },
); );
if (isVisualFailure(result)) jobStore.updateJob(jobId, { error: result.notes ?? "visual build failed" }); if (isVisualFailure(result) && stillRunning(jobId))
jobStore.updateJob(jobId, { error: result.notes ?? "visual build failed" });
await finishJob(jobId, result); await finishJob(jobId, result);
} catch (err) { } catch (err) {
if (loop.abortSignal.aborted) return;
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
jobStore.updateJob(jobId, { error: reason }); if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
await finishJob(jobId, { svg: "", notes: `FAILED: ${reason}` }); await finishJob(jobId, { svg: "", notes: `FAILED: ${reason}` });
} }
} }
+15 -2
View File
@@ -1,7 +1,7 @@
import { generateText, tool, type LanguageModel, type ModelMessage, type ToolResultPart } from "ai"; import { generateText, tool, type LanguageModel, type ModelMessage, type ToolResultPart } from "ai";
import { z } from "zod"; import { z } from "zod";
import { modelFor } from "../models.ts"; import { modelFor } from "../models.ts";
import type { VisualResult } from "../../core/contracts.ts"; import type { UsageStepLike, VisualResult } from "../../core/contracts.ts";
import { getHeadless, type HeadlessApi } from "./headless.ts"; import { getHeadless, type HeadlessApi } from "./headless.ts";
/** /**
@@ -37,6 +37,11 @@ export interface VisualMakerOptions {
maxIterations?: number; maxIterations?: number;
/** Live activity-line callback (updates the job record). */ /** Live activity-line callback (updates the job record). */
onActivity?: (line: string) => void; onActivity?: (line: string) => void;
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
abortSignal?: AbortSignal;
/** Money channel: one call per loop iteration (generateText), folded
* into JobRecord.usage. */
onUsage?: (usage: UsageStepLike) => void;
} }
const INSTRUCTIONS = `You are a visual maker — a diagram author + renderer for precise, spatial figures. You const INSTRUCTIONS = `You are a visual maker — a diagram author + renderer for precise, spatial figures. You
@@ -135,7 +140,15 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
try { try {
for (let iteration = 0; iteration < maxIterations; iteration++) { for (let iteration = 0; iteration < maxIterations; iteration++) {
const result = await generateText({ model: opts.model ?? modelFor("visualMaker"), system: INSTRUCTIONS, messages, tools }); const result = await generateText({
model: opts.model ?? modelFor("visualMaker"),
system: INSTRUCTIONS,
messages,
tools,
abortSignal: opts.abortSignal,
});
// Money channel: each iteration is one model call.
opts.onUsage?.(result.usage);
messages = [...messages, ...result.response.messages]; messages = [...messages, ...result.response.messages];
const toolCalls = result.toolCalls; const toolCalls = result.toolCalls;
+98 -6
View File
@@ -7,6 +7,7 @@
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md. * of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
*/ */
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import type { CallUsage } from "./chat.ts";
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ──────────── // ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
@@ -36,18 +37,64 @@ export interface JobRecord {
/** A sub-agent run's accumulated usage — the same accounting shape as a /** A sub-agent run's accumulated usage — the same accounting shape as a
* chat turn (contracts/chat.ts CallUsage), flattened to one running total * chat turn (contracts/chat.ts CallUsage), flattened to one running total
* plus a step count. */ * plus a step count. Token fields are optional exactly like CallUsage: a
* field NO model call reported stays undefined — absent is not zero (the
* money-channel doctrine; cost renders "—", never $0). */
export interface JobUsage { export interface JobUsage {
/** The OpenRouter model id that ran the sub-agent. */ /** The OpenRouter model id that ran the sub-agent. */
model: string; model: string;
/** Running totals summed over the run's model calls so far. */ /** Running totals summed over the run's model calls so far. */
inputTokens: number; inputTokens: number | undefined;
outputTokens: number; outputTokens: number | undefined;
cacheReadTokens: number; cacheReadTokens: number | undefined;
cacheWriteTokens: number; cacheWriteTokens: number | undefined;
reasoningTokens: number; reasoningTokens: number | undefined;
/** How many model calls the run has made (each bills separately). */ /** How many model calls the run has made (each bills separately). */
steps: number; steps: number;
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
* per-blob context line reads last-step input+output against the model's
* CONTEXT_LIMITS — never the cumulative totals. Absent until the first
* call completes; absent provider fields stay absent (never zero). */
lastStep?: CallUsage;
}
/** The per-model-call usage shape we fold into JobUsage — the SDK's
* LanguageModelUsage narrowed structurally (no runtime "ai" import: any
* object with these fields folds, including the real thing and test fakes). */
export interface UsageStepLike {
inputTokens?: number | undefined;
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined;
outputTokens?: number | undefined;
outputTokenDetails?: { reasoningTokens?: number } | undefined;
}
/** Fold one model call's usage into the run's running total (pure). A
* field neither the total nor the step reported stays undefined; once
* reported, later unreported calls contribute 0. The `steps` counter is
* the billing count: one per model call, reported or not. */
export function accumulateJobUsage(
total: JobUsage | undefined,
model: string,
step: UsageStepLike,
): JobUsage {
const add = (a: number | undefined, b: number | undefined): number | undefined =>
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
return {
model,
inputTokens: add(total?.inputTokens, step.inputTokens),
outputTokens: add(total?.outputTokens, step.outputTokens),
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
steps: (total?.steps ?? 0) + 1,
lastStep: {
inputTokens: step.inputTokens,
outputTokens: step.outputTokens,
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
},
};
} }
/** /**
@@ -155,6 +202,11 @@ export interface SubagentResultMeta {
jobId: string; jobId: string;
kind: JobKind; kind: JobKind;
toolCallId: string; toolCallId: string;
/** Present only on an abort steer (2026-09 abort semantics): the
* learner aborted the job (it lands as error "Aborted by learner");
* the teacher is told so its promise of a result doesn't hang
* silently in the lesson. */
aborted?: true;
} }
/** The data-subagent-artifact part payload: what the UI renders. */ /** The data-subagent-artifact part payload: what the UI renders. */
@@ -178,6 +230,20 @@ export interface SubagentSteerMessage extends UIMessage {
]; ];
} }
/** The abort steer: user-role like any steer, but NO artifact part —
* there is no result to render, only the teacher-facing text. Shares the
* result steer's deterministic id space (`steer_${jobId}`): steerDelivered
* matches on metadata jobId, so an abort and a (racing) result steer
* can never BOTH deliver — exactly one wakes the teacher. */
export interface AbortSteerMessage extends UIMessage {
role: "user";
metadata: { subagentResult: SubagentResultMeta & { aborted: true } };
parts: [{ type: "text"; text: string }];
}
/** Anything the wake pipeline may queue and deliver. */
export type SteerMessage = SubagentSteerMessage | AbortSteerMessage;
/** Whether a result is the failure shape (html/svg empty, failure brief). */ /** Whether a result is the failure shape (html/svg empty, failure brief). */
export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean { export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean {
if (kind === "research") { if (kind === "research") {
@@ -234,6 +300,32 @@ export function buildSubagentSteer(
}; };
} }
/** Build the abort steer for an aborted job (pure — shared by the
* abort path in src/server/jobs.ts and tests). Same id space as the result
* steer; see AbortSteerMessage. */
export function buildAbortSteer(
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
): AbortSteerMessage {
return {
id: `steer_${job.id}`,
role: "user",
metadata: {
subagentResult: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, aborted: true },
},
parts: [{ type: "text", text: abortText(job.kind) }],
};
}
/** What the teacher reads: one line telling it the result is not coming,
* how to acknowledge, and that re-dispatching is allowed. */
function abortText(kind: JobKind): string {
if (kind === "research")
return "Research aborted — the learner aborted the research job, so no brief is coming. Acknowledge it in one line and continue teaching without it (or offer to re-dispatch it).";
if (kind === "demo")
return "The demo job was aborted by the learner — no demo is coming. Acknowledge it in one line and teach the topic in prose instead (a re-dispatch is fine if the learner wants it).";
return "The figure job was aborted by the learner — no figure is coming. Acknowledge it in one line and teach without it (a re-dispatch is fine if the learner wants it).";
}
/** True when the transcript already carries this job's steer (idempotent delivery). */ /** True when the transcript already carries this job's steer (idempotent delivery). */
export function steerDelivered(messages: UIMessage[], jobId: string): boolean { export function steerDelivered(messages: UIMessage[], jobId: string): boolean {
return messages.some( return messages.some(
+73 -7
View File
@@ -33,13 +33,15 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import { import {
buildAbortSteer,
buildSubagentSteer, buildSubagentSteer,
isSubagentFailure, isSubagentFailure,
steerDelivered, steerDelivered,
type JobRecord, type JobRecord,
type SubagentSteerMessage, type SteerMessage,
} from "../core/contracts.ts"; } from "../core/contracts.ts";
import { findPendingToolCall } from "../core/pending.ts"; import { findPendingToolCall } from "../core/pending.ts";
import { fireJobAbort } from "../agent/subagents/aborts.ts";
import { stores } from "./stores.ts"; import { stores } from "./stores.ts";
import { isTurnRunning } from "./turn-liveness.ts"; import { isTurnRunning } from "./turn-liveness.ts";
@@ -50,7 +52,7 @@ export type TurnRunner = (chatId: string, messages: UIMessage[]) => Promise<numb
export type JobRespawn = (jobId: string) => void; export type JobRespawn = (jobId: string) => void;
interface QueuedSteer { interface QueuedSteer {
steer: SubagentSteerMessage; steer: SteerMessage;
resolve: () => void; resolve: () => void;
reject: (reason: Error) => void; reject: (reason: Error) => void;
} }
@@ -108,7 +110,7 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
function jobIdOf(steer: SubagentSteerMessage): string { function jobIdOf(steer: SteerMessage): string {
return (steer.metadata as { subagentResult: { jobId: string } }).subagentResult.jobId; return (steer.metadata as { subagentResult: { jobId: string } }).subagentResult.jobId;
} }
@@ -117,7 +119,7 @@ function jobIdOf(steer: SubagentSteerMessage): string {
* has completed (and the job status flipped); rejects when delivery fails * has completed (and the job status flipped); rejects when delivery fails
* unrecoverably. Never rejects for queueing itself. * unrecoverably. Never rejects for queueing itself.
*/ */
export function deliverJobResult(steer: SubagentSteerMessage): Promise<void> { export function deliverJobResult(steer: SteerMessage): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const s = state(); const s = state();
const chatId = chatIdOf(steer); const chatId = chatIdOf(steer);
@@ -145,13 +147,13 @@ export function deliverJobResult(steer: SubagentSteerMessage): Promise<void> {
* before firing, so a claim racing the pump converges to exactly one * before firing, so a claim racing the pump converges to exactly one
* delivery either way. * delivery either way.
*/ */
export function claimSteers(chatId: string): SubagentSteerMessage[] { export function claimSteers(chatId: string): SteerMessage[] {
const s = state(); const s = state();
const queue = s.queues.get(chatId); const queue = s.queues.get(chatId);
if (!queue || queue.length === 0) return []; if (!queue || queue.length === 0) return [];
const record = stores.chatStore.getChat(chatId); const record = stores.chatStore.getChat(chatId);
if (!record) return []; // the handler validated the chat; defensive only if (!record) return []; // the handler validated the chat; defensive only
const claimed: SubagentSteerMessage[] = []; const claimed: SteerMessage[] = [];
for (const entry of queue) { for (const entry of queue) {
const jobId = jobIdOf(entry.steer); const jobId = jobIdOf(entry.steer);
if (steerDelivered(record.messages, jobId)) { if (steerDelivered(record.messages, jobId)) {
@@ -167,7 +169,66 @@ export function claimSteers(chatId: string): SubagentSteerMessage[] {
return claimed; return claimed;
} }
function chatIdOf(steer: SubagentSteerMessage): string { /** Result of an abort attempt — the endpoint maps these to HTTP. */
export interface AbortOutcome {
ok: boolean;
reason?: "not_found" | "not_running";
}
/**
* ABORT (2026-09 dock semantics, user-decided): real kill, error with
* reason, the teacher still learns. The dock's END button reaches this
* through POST /api/activity/abort (body {kind: "job", id: jobId}); the
* route is master's thin composition — this is the job-side mechanism.
* Four things happen, in order:
*
* 1. KILL the spend: fire the job's registered AbortController (the
* runner registered it at loop start) — the in-flight model call
* aborts, not just the next loop check. The runner unwinds silently
* (its catch checks abortSignal.aborted; finishJob checks status).
* 2. DROP any queued result steer for this job (the delivering-result
* phase): resolve its entry without firing the wake. If the wake
* turn is ALREADY in flight (the pump shifted the entry), it's too
* late — the result steer lands, and the cancellation steer dedupes
* against it via steerDelivered (same jobId metadata).
* 3. FLIP the record to error "Aborted by learner" immediately — the
* poll reflects the user's action at once (the user chose
* error-with-reason over a distinct cancelled status). finalizeJob
* never overwrites a non-running status, so the flip is terminal no
* matter what the wake does.
* 4. STEER the teacher: a one-line abort note (buildAbortSteer) through
* the same wake pipeline, so the teacher's promise of a result
* doesn't hang silently in the lesson.
*
* The runner writes the usage folded so far in its finally (merge patch),
* so an aborted job's money record survives the kill.
*/
export function abortJob(jobId: string): AbortOutcome {
const job = stores.jobStore.getJob(jobId);
if (!job) return { ok: false, reason: "not_found" };
if (job.status !== "running") return { ok: false, reason: "not_running" };
fireJobAbort(jobId); // no-op when the loop already returned (delivering)
// Drop any queued steers for this job (their deliver() resolves unfired).
const queue = state().queues.get(job.chatId);
if (queue) {
const remaining = queue.filter((e) => jobIdOf(e.steer) !== jobId);
if (remaining.length === 0) state().queues.delete(job.chatId);
else state().queues.set(job.chatId, remaining);
for (const entry of queue) {
if (jobIdOf(entry.steer) === jobId) entry.resolve();
}
}
stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" });
void deliverJobResult(buildAbortSteer(job)).catch(() => {
/* deliverJobResult marks the job error on unrecoverable failure */
});
return { ok: true };
}
function chatIdOf(steer: SteerMessage): string {
// The chat is resolvable from the job record; the steer's metadata // The chat is resolvable from the job record; the steer's metadata
// carries jobId, which indexes the store. // carries jobId, which indexes the store.
const jobId = jobIdOf(steer); const jobId = jobIdOf(steer);
@@ -285,6 +346,11 @@ function finalizeJob(jobId: string): void {
} }
function failJob(jobId: string, err: Error): void { function failJob(jobId: string, err: Error): void {
// An aborted job stays aborted even when its (abort) steer
// delivery fails — the user's action is the terminal fact, not the
// delivery error. Non-running records are never overwritten here.
const job = stores.jobStore.getJob(jobId);
if (!job || job.status !== "running") return;
stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" }); stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" });
} }
+13 -1
View File
@@ -49,7 +49,7 @@ import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts"; import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts"; import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts"; import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
import { buildSubagentSteer, isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts"; import { buildAbortSteer, buildSubagentSteer, isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts";
import type { JobRecord } from "../src/core/contracts.ts"; import type { JobRecord } from "../src/core/contracts.ts";
import { createTeacherTools } from "../src/agent/tools.ts"; import { createTeacherTools } from "../src/agent/tools.ts";
import { chatStore } from "../src/store/chats.ts"; import { chatStore } from "../src/store/chats.ts";
@@ -342,6 +342,18 @@ describe("steer card rendering (the delivered artifact)", () => {
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render"); assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
}); });
it("renders the abort chip for an aborted job's steer (2026-09 dock semantics)", () => {
const steer = buildAbortSteer({ id: "jc", kind: "research", toolCallId: "cc" });
assert.equal(steer.metadata.subagentResult.aborted, true);
assert.equal(steer.parts.length, 1, "no artifact part on an abort");
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
assert.match(html, /job-aborted-badge/);
assert.match(html, /research/, "the job's kind, lower-cased like the complete-badge idiom");
assert.match(html, /aborted by the learner/);
// the teacher-facing protocol text never renders for the learner
assert.ok(!html.includes("no brief is coming"), "no protocol glue in the chip");
});
it("renders a quiet Research-complete one-liner whose collapsed <details> carries the full brief", () => { it("renders a quiet Research-complete one-liner whose collapsed <details> carries the full brief", () => {
const steer = buildSubagentSteer( const steer = buildSubagentSteer(
{ id: "jr", kind: "research", toolCallId: "cr" }, { id: "jr", kind: "research", toolCallId: "cr" },
+246 -1
View File
@@ -31,18 +31,23 @@ import type { UIMessage } from "ai";
import { MockLanguageModelV4, simulateReadableStream } from "ai/test"; import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider"; import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
import { import {
accumulateJobUsage,
buildAbortSteer,
buildSubagentSteer, buildSubagentSteer,
isSubagentFailure, isSubagentFailure,
steerDelivered, steerDelivered,
type DemoResult, type DemoResult,
type JobUsage,
type ResearchResult, type ResearchResult,
type SteerMessage,
type SubagentSteerMessage, type SubagentSteerMessage,
type VisualResult, type VisualResult,
} from "../src/core/contracts.ts"; } from "../src/core/contracts.ts";
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts"; import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { createTeacherTools } from "../src/agent/tools.ts"; import { createTeacherTools } from "../src/agent/tools.ts";
import { handleChatRequest } from "../src/server/chat-handler.ts"; import { handleChatRequest } from "../src/server/chat-handler.ts";
import { claimSteers, deliverJobResult, setTurnRunner } from "../src/server/jobs.ts"; import { abortJob, claimSteers, deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts"; import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
import { chatStore } from "../src/store/chats.ts"; import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts"; import { jobStore } from "../src/store/jobs.ts";
@@ -170,6 +175,57 @@ describe("steer helpers (contracts.ts)", () => {
assert.deepEqual(data.data.result, research); assert.deepEqual(data.data.result, research);
}); });
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");
});
it("buildAbortSteer: metadata-aborted, text-only (no artifact), same deterministic id space as the result steer", () => {
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");
assert.equal(steer.parts.length, 1);
assert.ok(steer.parts[0]!.type === "text" && /aborted/.test(steer.parts[0].text));
});
it("isSubagentFailure: failure shapes only (empty html/svg, failure brief)", () => { it("isSubagentFailure: failure shapes only (empty html/svg, failure brief)", () => {
assert.equal(isSubagentFailure("research", research), false); assert.equal(isSubagentFailure("research", research), false);
assert.equal(isSubagentFailure("research", { brief: "Research failed: x", sources: [] }), true); assert.equal(isSubagentFailure("research", { brief: "Research failed: x", sources: [] }), true);
@@ -836,3 +892,192 @@ describe("prompt composition", () => {
assert.ok(composeInstructions().length <= 60_000, "composed system prompt over the 60k guard"); assert.ok(composeInstructions().length <= 60_000, "composed system prompt over the 60k guard");
}); });
}); });
// ─── abort (2026-09 semantics: real kill, cancelled status, teacher told) ───
describe("abort (src/server/jobs.ts abortJob)", () => {
/** A hanging model that only settles when its abort signal fires — the
* real-kill path: the in-flight call itself is killed. */
function hangingModel(): MockLanguageModelV4 {
return new MockLanguageModelV4({
doGenerate: ({ abortSignal }) =>
new Promise((_resolve, reject) => {
// an ALREADY-aborted signal never fires the event — real
// providers reject an aborted fetch at once, the mock must too
if (abortSignal?.aborted) {
reject(new DOMException("The operation was aborted", "AbortError"));
return;
}
abortSignal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted", "AbortError"));
});
}),
});
}
/** One step (with usage) then a hang — proves usage-so-far survives the kill. */
function oneStepThenHangModel(): MockLanguageModelV4 {
let turn = 0;
return new MockLanguageModelV4({
doGenerate: ({ abortSignal }) => {
turn++;
if (turn === 1) {
return Promise.resolve({
content: [
{
type: "tool-call" as const,
toolCallId: "ws1",
toolName: "web_search",
input: JSON.stringify({ query: "anything" }),
},
],
finishReason: { unified: "tool-calls" as const, raw: "tool-calls" },
usage: usage(),
warnings: [],
});
}
return new Promise((_resolve, reject) => {
if (abortSignal?.aborted) {
reject(new DOMException("The operation was aborted", "AbortError"));
return;
}
abortSignal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted", "AbortError"));
});
});
},
});
}
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]!;
assert.equal(last.metadata.subagentResult.aborted, true);
assert.equal(last.id, `steer_${jobId}`);
assert.equal(last.parts.length, 1, "text only — no artifact part");
// 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");
assert.equal(job.usage!.inputTokens, 10);
assert.equal(job.usage!.outputTokens, 5);
} 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");
assert.equal(fired[0]!.metadata.subagentResult.aborted, true, "the abort steer, not the result");
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" });
});
});