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:
@@ -26,6 +26,7 @@ function assistantMessage(
|
||||
id: string,
|
||||
seed: number,
|
||||
block?: { groupId: string; index: number },
|
||||
turnId?: string,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
@@ -33,14 +34,20 @@ function assistantMessage(
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
|
||||
...(turnId ? { turnId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, seed: number): Extract<StreamItem, { kind: "tool_call" }> {
|
||||
function toolCall(
|
||||
id: string,
|
||||
seed: number,
|
||||
turnId?: string,
|
||||
): Extract<StreamItem, { kind: "tool_call" }> {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: timestamp(seed),
|
||||
...(turnId ? { turnId } : {}),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
@@ -143,6 +150,44 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it("does not borrow an assistant footer across adjacent tagged tool-only turns", () => {
|
||||
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||
const nextTool = toolCall("next-tool", 2, "turn-2");
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [priorAssistant, nextTool],
|
||||
timingIds: [priorAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
|
||||
});
|
||||
|
||||
it("recomputes cached history layout when only the live boundary turn changes", () => {
|
||||
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||
const history = [priorAssistant];
|
||||
const liveHead = [toolCall("live-tool", 2, "turn-1")];
|
||||
const strategy = strategyFor("web");
|
||||
const first = layoutStream({
|
||||
strategy,
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
||||
const second = layoutStream({
|
||||
strategy,
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
expect(footerOwners(first)).toEqual([]);
|
||||
expect(footerOwners(second)).toEqual([priorAssistant.id]);
|
||||
});
|
||||
it.each(["web", "android"] as const)(
|
||||
"marks only the active live-head assistant block as streaming on %s",
|
||||
(platform) => {
|
||||
@@ -249,6 +294,18 @@ describe("layoutStream", () => {
|
||||
expect(assistantRow.frameOrder).toBe("footer-then-content");
|
||||
});
|
||||
|
||||
it("keeps an accepted steer inline while its canonical turn is active", () => {
|
||||
const assistant = { ...assistantMessage("a1", 2), turnId: "turn-1" };
|
||||
const steer = { ...userMessage("u2", 3), turnId: "turn-1" };
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
isTurnActive: true,
|
||||
tail: [{ ...userMessage("u1", 1), turnId: "turn-1" }, assistant, steer],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
expect(footerOwners(layout)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps forward stream content before its completed footer", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
|
||||
import { continuesTurn, isTurnBoundary } from "./turn-membership";
|
||||
|
||||
export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none";
|
||||
|
||||
@@ -87,6 +88,7 @@ function findLatestAssistantInTurn(input: {
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
let laterItem: StreamItem | null = null;
|
||||
|
||||
while (true) {
|
||||
for (
|
||||
@@ -95,12 +97,13 @@ function findLatestAssistantInTurn(input: {
|
||||
index = input.strategy.getNeighborIndex(index, "above")
|
||||
) {
|
||||
const item = items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
if (!item || (laterItem && !continuesTurn(item, laterItem))) {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return { item, items, index };
|
||||
}
|
||||
laterItem = item;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -157,7 +160,7 @@ function resolveCompletedFooter(input: {
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}): TurnFooterHost | null {
|
||||
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
|
||||
if (input.item.kind === "user_message" || !isTurnBoundary(input.item, input.belowItem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -194,8 +197,10 @@ function getToolSequence(input: {
|
||||
return "none";
|
||||
}
|
||||
|
||||
const hasAbove = isToolSequenceItem(input.aboveItem);
|
||||
const hasBelow = isToolSequenceItem(input.belowItem);
|
||||
const hasAbove =
|
||||
isToolSequenceItem(input.aboveItem) && continuesTurn(input.aboveItem, input.item);
|
||||
const hasBelow =
|
||||
isToolSequenceItem(input.belowItem) && continuesTurn(input.item, input.belowItem);
|
||||
if (hasAbove && hasBelow) {
|
||||
return "middle";
|
||||
}
|
||||
@@ -273,7 +278,9 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
||||
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
||||
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
|
||||
isLastInToolSequence:
|
||||
isToolSequenceItem(item) &&
|
||||
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
|
||||
frameOrder: input.frameOrder,
|
||||
phase: input.phase,
|
||||
};
|
||||
@@ -297,14 +304,14 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
|
||||
let history: StreamLayoutItem[];
|
||||
if (input.history.length > 0) {
|
||||
// The cache key encodes every input that can change history layout. liveHeadBoundaryItem.id
|
||||
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
|
||||
// item borders history), so cached layout stays valid between flushes.
|
||||
// The cache key encodes every input that can change history layout. The boundary turn ID
|
||||
// is membership, so changing it must not reuse a layout from the adjacent turn.
|
||||
const historyCacheKey = [
|
||||
frameOrder,
|
||||
historyBoundaryIndex ?? "null",
|
||||
liveHeadBoundaryItem?.id ?? "null",
|
||||
liveHeadBoundaryItem?.kind ?? "null",
|
||||
liveHeadBoundaryItem?.turnId ?? "null",
|
||||
auxiliaryTurnFooter?.itemId ?? "null",
|
||||
].join(":");
|
||||
let byKey = historyLayoutCache.get(input.history);
|
||||
|
||||
@@ -38,6 +38,24 @@ function assistantMessage(id: string, text: string, seed: number): StreamItem {
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: createTimestamp(seed),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
toolCallId: id,
|
||||
toolName: "Shell",
|
||||
arguments: "echo hi",
|
||||
result: null,
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveStreamRenderStrategy", () => {
|
||||
it("uses forward_stream on web", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
@@ -196,6 +214,23 @@ describe("neighbor and traversal semantics", () => {
|
||||
).toBe("assistant-1\n\nassistant-2");
|
||||
});
|
||||
|
||||
it("does not collect copy or fork content across an adjacent tagged turn", () => {
|
||||
const chronological: StreamItem[] = [
|
||||
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
|
||||
{ ...toolCall("tool", 2), turnId: "turn-2" },
|
||||
{ ...assistantMessage("a2", "second turn", 3), turnId: "turn-2" },
|
||||
];
|
||||
const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
|
||||
|
||||
expect(
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy,
|
||||
items: chronological,
|
||||
startIndex: 2,
|
||||
}),
|
||||
).toBe("second turn");
|
||||
});
|
||||
|
||||
it("returns undefined neighbor when index would be out of bounds", () => {
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { continuesTurn } from "./turn-membership";
|
||||
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
||||
import type {
|
||||
BottomAnchorLocalRequest,
|
||||
@@ -165,18 +166,20 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
|
||||
},
|
||||
collectAssistantTurnContent: (items, startIndex) => {
|
||||
const messages: string[] = [];
|
||||
let laterItem: StreamItem | null = null;
|
||||
for (
|
||||
let index = startIndex;
|
||||
index >= 0 && index < items.length;
|
||||
index += config.assistantTurnTraversalStep
|
||||
) {
|
||||
const currentItem = items[index];
|
||||
if (currentItem.kind === "user_message") {
|
||||
if (!currentItem || (laterItem && !continuesTurn(currentItem, laterItem))) {
|
||||
break;
|
||||
}
|
||||
if (currentItem.kind === "assistant_message") {
|
||||
messages.push(currentItem.text);
|
||||
}
|
||||
laterItem = currentItem;
|
||||
}
|
||||
return messages.toReversed().join("\n\n");
|
||||
},
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { layoutStream } from "./layout";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { continuesTurn } from "./turn-membership";
|
||||
|
||||
function at(second: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${second.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function user(id: string, second: number, turnId: string): StreamItem {
|
||||
return { kind: "user_message", id, text: id, timestamp: at(second), turnId };
|
||||
}
|
||||
|
||||
function assistant(id: string, second: number, turnId: string): StreamItem {
|
||||
return { kind: "assistant_message", id, text: id, timestamp: at(second), turnId };
|
||||
}
|
||||
|
||||
function runningTool(id: string, second: number, turnId: string): StreamItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: at(second),
|
||||
turnId,
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: id,
|
||||
name: "Bash",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function layoutFor(items: StreamItem[], isTurnActive: boolean) {
|
||||
const model = buildAgentStreamRenderModel({
|
||||
isTurnActive,
|
||||
activeTurnStartedAt: isTurnActive ? at(1) : null,
|
||||
tail: items,
|
||||
head: [],
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
return {
|
||||
model,
|
||||
layout: layoutStream({
|
||||
strategy: resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false }),
|
||||
isTurnActive,
|
||||
history: model.segments.historyMounted,
|
||||
liveHead: model.segments.liveHead,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function completedFooterIds(layout: ReturnType<typeof layoutFor>["layout"]): string[] {
|
||||
return [
|
||||
...layout.history.flatMap((row) => (row.completedFooter ? [row.completedFooter.itemId] : [])),
|
||||
...layout.liveHead.flatMap((row) => (row.completedFooter ? [row.completedFooter.itemId] : [])),
|
||||
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
|
||||
];
|
||||
}
|
||||
|
||||
describe("canonical turn membership", () => {
|
||||
it("stops tagged traversal at every mismatching turn ID", () => {
|
||||
expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(continuesTurn(runningTool("first", 1, "turn-1"), assistant("next", 2, "turn-1"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"Claude",
|
||||
(turnId: string) => [
|
||||
user("prompt", 1, turnId),
|
||||
runningTool("sleep", 2, turnId),
|
||||
user("hello", 3, turnId),
|
||||
],
|
||||
],
|
||||
[
|
||||
"Codex",
|
||||
(turnId: string) => [
|
||||
user("prompt", 1, turnId),
|
||||
assistant("preface", 2, turnId),
|
||||
runningTool("sleep", 3, turnId),
|
||||
user("hello", 4, turnId),
|
||||
],
|
||||
],
|
||||
] as const)("keeps %s-shaped active steers in one visible turn", (_provider, build) => {
|
||||
const turnId = "turn-1";
|
||||
const active = layoutFor(build(turnId), true);
|
||||
|
||||
expect(completedFooterIds(active.layout)).toEqual([]);
|
||||
expect(active.model.turnTiming.runningStartedAt).toEqual(at(1));
|
||||
|
||||
const completedItems = [...build(turnId), assistant("done", 9, turnId)];
|
||||
const completed = layoutFor(completedItems, false);
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||
startedAt: at(1),
|
||||
completedAt: at(9),
|
||||
durationMs: 8000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
/**
|
||||
* Canonical turn IDs take precedence. Timelines without them retain the legacy
|
||||
* user-message boundary rule so old daemons and persisted rows need no rewrite.
|
||||
*/
|
||||
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
if (!previous || !next) return false;
|
||||
if (previous.turnId !== undefined && next.turnId !== undefined) {
|
||||
return previous.turnId === next.turnId;
|
||||
}
|
||||
return next.kind !== "user_message";
|
||||
}
|
||||
|
||||
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
return previous !== null && next !== null && !continuesTurn(previous, next);
|
||||
}
|
||||
|
||||
/** Whether `item` begins a new chronological turn after `previous`. */
|
||||
export function startsNewTurn(item: StreamItem, previous: StreamItem | null): boolean {
|
||||
return previous === null || isTurnBoundary(previous, item);
|
||||
}
|
||||
|
||||
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
|
||||
return turnId !== null && item.turnId === turnId;
|
||||
}
|
||||
Reference in New Issue
Block a user