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:
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user