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
+58 -1
View File
@@ -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({
+15 -8
View File
@@ -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",
+4 -1
View File
@@ -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;
}