Add active-turn steering (#3394)

* feat(agents): add active-turn steering

Let users choose whether an immediate prompt interrupts or joins the active turn. The daemon owns exact-turn admission, provider delivery, safe fallback, and canonical timeline identity while queueing remains client-side.

* fix(agents): stabilize steering history and replay QA

* fix(agents): preserve steering timeline order

* fix(agents): reconcile resumed provider history

* fix(agents): serialize steering with cancellation

* test(agents): prove Claude multi-tool steering

* test(agents): force explicit history hydration

* fix(agents): flush output before steer admission
This commit is contained in:
Mohamed Boudra
2026-08-16 13:58:40 +02:00
committed by GitHub
parent 16120ebff1
commit 42245d139a
69 changed files with 5203 additions and 269 deletions
@@ -197,6 +197,32 @@ describe("deriveAgentStreamTurnLiveness", () => {
});
});
describe("timeline turn membership compatibility", () => {
it("hydrates tagged rows while retaining legacy rows without a turn ID", () => {
const hydrated = hydrateStreamState([
{
event: {
type: "timeline",
provider: "claude",
turnId: "turn-1",
item: { type: "user_message", text: "prompt", clientMessageId: "prompt-id" },
},
timestamp: new Date(1000),
},
{
event: {
type: "timeline",
provider: "claude",
item: { type: "assistant_message", text: "legacy" },
},
timestamp: new Date(2000),
},
]);
expect(hydrated.map((item) => item.turnId)).toEqual(["turn-1", undefined]);
});
});
describe("detached timeline windows", () => {
it("does not apply or catch up live events while viewing an older window", () => {
const currentTail = [makeAssistantItem("older window")];
@@ -285,8 +311,17 @@ describe("processTimelineResponse", () => {
text: "local prompt",
timestamp: new Date(1000),
timelineCursor: { epoch: "epoch-1", seq: 1 },
turnId: "turn-1",
});
const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail")];
const hello = createUserMessage({
id: "hello",
clientMessageId: "hello-client",
text: "hello",
timestamp: new Date(1500),
timelineCursor: { epoch: "epoch-1", seq: 2 },
turnId: "turn-1",
});
const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail"), hello];
const currentHead = [makeAssistantItem("existing head", "existing-head")];
const result = processTimelineResponse({
@@ -314,6 +349,7 @@ describe("processTimelineResponse", () => {
],
},
});
expect(result.tail.find((item) => item.id === "hello")?.turnId).toBe("turn-1");
expect(result.commit).toBe("discard");
expect(result.tail).toBe(currentTail);
@@ -126,6 +126,7 @@ interface TimelineResponseEntry {
sourceSeqRanges?: TimelineSeqRange[];
collapsed?: string[];
provider: string;
turnId?: string;
item: Record<string, unknown>;
timestamp: string;
}
@@ -1269,6 +1270,7 @@ export function processTimelineResponse(
type: "timeline",
provider: entry.provider,
item: entry.item,
...(entry.turnId ? { turnId: entry.turnId } : {}),
} as AgentStreamEventPayload,
timestamp: new Date(entry.timestamp),
}));
+6 -3
View File
@@ -1,4 +1,5 @@
import type { StreamItem } from "@/types/stream";
import { startsNewTurn } from "@/agent-stream/turn-membership";
export interface TurnTiming {
startedAt: Date;
@@ -21,6 +22,7 @@ export function deriveStreamTurnTiming(params: {
let currentUserAt: Date | null = null;
let currentLastItemAt: Date | null = null;
let currentAssistantIds: string[] = [];
let previousItem: StreamItem | null = null;
const flushCompletedTurn = () => {
if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) {
@@ -37,20 +39,21 @@ export function deriveStreamTurnTiming(params: {
};
const visitItem = (item: StreamItem) => {
if (item.kind === "user_message") {
if (startsNewTurn(item, previousItem)) {
flushCompletedTurn();
currentUserAt = item.timestamp;
currentUserAt = item.kind === "user_message" ? item.timestamp : null;
currentLastItemAt = null;
currentAssistantIds = [];
return;
}
if (!currentUserAt) {
previousItem = item;
return;
}
currentLastItemAt = item.timestamp;
if (item.kind === "assistant_message") {
currentAssistantIds.push(item.id);
}
previousItem = item;
};
for (const item of params.tail) {