Skip to content

Commit 940d4e5

Browse files
committed
fix(director): ignore empty source ids in live source tracking
An empty-string source id on a completion or cycle source no longer clobbers the learned source id used to stamp retry decisions.
1 parent 474ee30 commit 940d4e5

2 files changed

Lines changed: 171 additions & 2 deletions

File tree

src/agent/director.test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,172 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => {
366366
);
367367
});
368368
});
369+
370+
// CL-7973: the director's live source id (which stamps retry decisions so a
371+
// mid-session /model switch remaps the xAI short-429 handling) is observable
372+
// only through the retry policy it hands to each infer action. An xAI-gated
373+
// capacity error retries when the tracked id is an xAI source and aborts
374+
// otherwise, so driving tracking events then invoking the attached policy
375+
// reads the tracked id without reaching into privates.
376+
type LiveRetryPolicy = (situation: {
377+
attempt: number;
378+
elapsedMs: number;
379+
error: { category: "protocol_mismatch"; message: string };
380+
}) => Promise<{ kind: string }> | { kind: string };
381+
382+
function textCompletion(sourceId?: string): ReactorInboundEvent {
383+
const turn = {
384+
role: "assistant",
385+
model: "test",
386+
timestamp: 0,
387+
content: [{ type: "text", text: "done work" }],
388+
};
389+
const event: Record<string, unknown> = {
390+
type: "inference.done",
391+
turn,
392+
usage: { input: 0, output: 0 },
393+
};
394+
if (sourceId !== undefined)
395+
event["source"] = { sourceId, provider: "p", model: "test" };
396+
return event as unknown as ReactorInboundEvent;
397+
}
398+
399+
function stateWithCycleSource(sourceId: string): ReactorState {
400+
return {
401+
turns: [],
402+
lastCycleSource: { sourceId, provider: "p", model: "test" },
403+
} as unknown as ReactorState;
404+
}
405+
406+
async function liveRetryPolicy(
407+
director: ReturnType<typeof createChatDirector>,
408+
capabilities: ReactorCapabilities,
409+
): Promise<LiveRetryPolicy> {
410+
await director.decide(toolOnlyTurn("source-probe"), mockState, capabilities);
411+
const actions = actionsArray(
412+
await director.decide(
413+
toolDoneEvent("source-probe"),
414+
mockState,
415+
capabilities,
416+
),
417+
);
418+
const infer = actions.find((a) => a.type === "infer") as
419+
| { type: "infer"; options?: { retryPolicy?: LiveRetryPolicy } }
420+
| undefined;
421+
if (infer?.options?.retryPolicy === undefined)
422+
throw new Error("expected an infer action carrying the live retry policy");
423+
return infer.options.retryPolicy;
424+
}
425+
426+
async function isXaiStamped(policy: LiveRetryPolicy): Promise<boolean> {
427+
const decision = await policy({
428+
attempt: 1,
429+
elapsedMs: 0,
430+
error: {
431+
category: "protocol_mismatch",
432+
message: "The model is currently at capacity",
433+
},
434+
});
435+
return decision.kind === "retry";
436+
}
437+
438+
describe("ChatDirector live source-id tracking (CL-7973)", () => {
439+
test("a sourceless or empty-string completion never wipes the learned id", async () => {
440+
const director = createChatDirector("system", [], {
441+
onTasksChange: () => undefined,
442+
provider: { providerName: "test-provider" },
443+
});
444+
const capabilities = makeCapabilities();
445+
const policy = await liveRetryPolicy(director, capabilities);
446+
447+
// The seed id is not an xAI source, so the capacity error aborts.
448+
expect(await isXaiStamped(policy)).toBe(false);
449+
450+
// A completion stamps the source that served it.
451+
await director.decide(
452+
textCompletion("xai/learned"),
453+
mockState,
454+
capabilities,
455+
);
456+
expect(await isXaiStamped(policy)).toBe(true);
457+
458+
// A completion carrying no source keeps the learned id.
459+
await director.decide(textCompletion(), mockState, capabilities);
460+
expect(await isXaiStamped(policy)).toBe(true);
461+
462+
// An empty-string source id never clobbers the learned id.
463+
await director.decide(textCompletion(""), mockState, capabilities);
464+
expect(await isXaiStamped(policy)).toBe(true);
465+
466+
// An empty-string cycle source never clobbers it either.
467+
await director.decide(
468+
toolDoneEvent("empty-cycle"),
469+
stateWithCycleSource(""),
470+
capabilities,
471+
);
472+
expect(await isXaiStamped(policy)).toBe(true);
473+
});
474+
475+
test("a cycle source remaps tracking on a non-inference event", async () => {
476+
const director = createChatDirector("system", [], {
477+
onTasksChange: () => undefined,
478+
provider: { providerName: "test-provider" },
479+
});
480+
const capabilities = makeCapabilities();
481+
const policy = await liveRetryPolicy(director, capabilities);
482+
expect(await isXaiStamped(policy)).toBe(false);
483+
484+
// tool.done carries no source of its own; the harness's cycle source
485+
// covers the turn and remaps tracking from it.
486+
await director.decide(
487+
toolDoneEvent("cycle-remap"),
488+
stateWithCycleSource("xai/cycle"),
489+
capabilities,
490+
);
491+
expect(await isXaiStamped(policy)).toBe(true);
492+
});
493+
494+
test("a drained fleet capitulates to the terminal action after the nudge budget", async () => {
495+
const director = createChatDirector("system", [], {
496+
onTasksChange: () => undefined,
497+
provider: { providerName: "test-provider" },
498+
});
499+
director.restoreTasks([{ id: "t1", title: "keep going", status: "todo" }]);
500+
const capabilities = makeCapabilities();
501+
502+
// Without the idle-with-fleet allowance (drained fleet), a terminal base
503+
// action with open tasks re-infers with the open-task nudge a bounded
504+
// number of times, then lets the terminal action through — the accepted
505+
// loss stays locked in rather than resuming the nudge.
506+
for (let i = 0; i < 3; i++) {
507+
const actions = actionsArray(
508+
await director.decide(textCompletion(), mockState, capabilities),
509+
);
510+
expect(actions.some((a) => a.type === "infer")).toBe(true);
511+
expect(actions.some((a) => a.type === "reply")).toBe(false);
512+
}
513+
const terminal = actionsArray(
514+
await director.decide(textCompletion(), mockState, capabilities),
515+
);
516+
expect(terminal.some((a) => a.type === "infer")).toBe(false);
517+
expect(terminal.some((a) => a.type === "reply")).toBe(true);
518+
});
519+
520+
test("a cycle source wins over a contradictory event source", async () => {
521+
const director = createChatDirector("system", [], {
522+
onTasksChange: () => undefined,
523+
provider: { providerName: "test-provider" },
524+
});
525+
const capabilities = makeCapabilities();
526+
const policy = await liveRetryPolicy(director, capabilities);
527+
528+
// The harness's call-start snapshot is authoritative over the event's
529+
// own stamp, so when both are present the cycle source defines tracking.
530+
await director.decide(
531+
textCompletion("other/default"),
532+
stateWithCycleSource("xai/cycle"),
533+
capabilities,
534+
);
535+
expect(await isXaiStamped(policy)).toBe(true);
536+
});
537+
});

src/agent/director.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -991,10 +991,10 @@ class ChatDirectorImpl extends DefaultDirector {
991991
// lastCycleSource covers turns whose event carries no source.
992992
if (event.type === "inference.done") {
993993
const served = event.source?.sourceId;
994-
if (typeof served === "string") this.currentSourceId = served;
994+
if (served !== undefined && served !== "") this.currentSourceId = served;
995995
}
996996
const cycled = state.lastCycleSource?.sourceId;
997-
if (typeof cycled === "string") this.currentSourceId = cycled;
997+
if (cycled !== undefined && cycled !== "") this.currentSourceId = cycled;
998998
if (onTurnBoundary(event)) {
999999
this.compaction.noteInferenceDone(event, turns);
10001000
}

0 commit comments

Comments
 (0)