feat: Wave C pivot - steer markers, not follow-ups
Owner correction (2026-09-26, later): a real lesson is ONE continuous teach->quiz->teach run - there are no run-would-stop moments mid-lesson, and the run ends that DO exist are quiz stopper exits where delivery must not fire. Subagent results must land BETWEEN messages INSIDE the run, never splitting a toolCall from its toolResult. Vehicle 1 now hands markers to agent.steer() - pi's STEERING queue: the loop drains it at TURN BOUNDARIES (after a completed assistant message AND its tool results - the bonded pair survives injection) and again at RUN START (a continuation drains before its first LLM call), then CONTINUES the run with the marker in context. steeringMode "all" replaces followUpMode. registerLiveAgent moved BEFORE runPiTurn and now hands pending markers itself: the start poll drains the queue SYNCHRONOUSLY inside the runPiTurn call, so a later registration would miss it. A marker stranded by a quiz-terminated run therefore rides the ANSWER run's first LLM call - the native replacement for the old continuation coalescing. Settlement reconciliation, the idle wake, probe-handoff exclusion, and the registry-as-truth design are UNCHANGED; stranding is now exactly the stopper-exit case. Tests: jobs 45/45 (fakeAgent.steer; new pin - registration steers pending markers into the new run, parked wake never fires); chat-flow 24/24 (new e2e through the real composition - a gated mid-run turn, a marker delivered while the turn streams, record shape [call, result, MARKER, response] with the marker adjacent to the completed unit, the model SEES the marker in its next prompt, zero wakes); full suite 999/999, tsc/lint/build green. Live smoke (PORT=3004): research dispatched in the probe turn parked behind the dangling quiz; on the quiz answer the marker injected at the answer run's START poll - record [answer result, marker, teacher response], job flipped done/delivered at settlement, brief card rendered with its sources toggle. Plan doc: docs/PI-NATIVE-MIGRATION-PLAN.md gains the pivot section. Deferred (owner list): thinking blocks rendering after messages; vanishing tool-call output tokens.
This commit is contained in:
+51
-4
@@ -854,14 +854,14 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
|
||||
// ─── continuation coalescing (the delivery-starvation fix) ───────────────────
|
||||
|
||||
describe("Wave C: live follow-up handoff + settlement reconciliation", () => {
|
||||
/** A minimal stand-in for pi's Agent — the pump touches only followUp(). */
|
||||
describe("Wave C: live steering handoff + settlement reconciliation", () => {
|
||||
/** A minimal stand-in for pi's Agent — the pump touches only steer(). */
|
||||
function fakeAgent(): { handed: SteerMessage[]; agent: unknown } {
|
||||
const handed: SteerMessage[] = [];
|
||||
return { handed, agent: { followUp: (m: SteerMessage) => handed.push(m) } };
|
||||
return { handed, agent: { steer: (m: SteerMessage) => handed.push(m) } };
|
||||
}
|
||||
|
||||
it("hands queued markers to the LIVE run (pi followUp) — no wake fires, the registry waits", async () => {
|
||||
it("hands queued markers to the LIVE run (pi steering queue) — no wake fires, the registry waits", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
const { handed, agent } = fakeAgent();
|
||||
let fired = 0;
|
||||
@@ -944,6 +944,53 @@ describe("Wave C: live follow-up handoff + settlement reconciliation", () => {
|
||||
assert.equal(fired[0]![0]!.id, handoff.id, "the handoff rode the wake");
|
||||
});
|
||||
|
||||
it("registerLiveAgent immediately steers pending markers into the NEW run (the parked-behind-quiz path)", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
const { handed, agent } = fakeAgent();
|
||||
let fired = 0;
|
||||
setTurnRunner(async () => {
|
||||
fired++;
|
||||
return 200;
|
||||
});
|
||||
|
||||
// A dangling question has the pump parked in waitForIdle: the
|
||||
// marker is pending but no live run exists to take it.
|
||||
chatStore.saveMessages(chatId, [
|
||||
...nativeToolMessages("teach me", "probe_done", "call-q", { ok: true }),
|
||||
{
|
||||
id: "a-q",
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "quiz-call", name: "quiz", arguments: { question: "q", choices: ["a", "b"] } as never }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
]);
|
||||
const delivery = deliverJobResult(steer);
|
||||
await delay(300); // let the pump park behind the dangling question
|
||||
assert.equal(handed.length, 0, "nothing to hand to — no live agent");
|
||||
|
||||
// The learner answers: the handler registers the answer run's agent
|
||||
// BEFORE runPiTurn, so the marker is steered into it synchronously —
|
||||
// it rides the run's START steering poll (first LLM call).
|
||||
assert.ok(beginTurn(chatId));
|
||||
registerLiveAgent(chatId, agent as never);
|
||||
assert.equal(handed.length, 1, "registration itself steered the marker in");
|
||||
assert.equal(handed[0]!.id, steer.id);
|
||||
|
||||
// The answer run persisted the marker — the parked pump's wake
|
||||
// re-check resolves it without ever firing a turn.
|
||||
unregisterLiveAgent(chatId);
|
||||
settleSteers(chatId, [steer as never]);
|
||||
endTurn(chatId);
|
||||
await delivery;
|
||||
assert.equal(fired, 0, "the parked wake never fired — the run delivered");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
||||
});
|
||||
|
||||
it("the idle wake carries EVERY pending marker in ONE turn", async () => {
|
||||
const { chatId, steer } = setup();
|
||||
const job2 = jobStore.createJob({ kind: "research", chatId, toolCallId: "call-2" });
|
||||
|
||||
Reference in New Issue
Block a user