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
+67
View File
@@ -179,6 +179,7 @@ interface FakeSendCall {
text: string;
options: {
messageId: string;
activeTurnBehavior?: "interrupt" | "steer";
images: Array<{ data: string; mimeType: string }>;
attachments: AgentAttachment[];
};
@@ -417,6 +418,50 @@ describe("pickAndPersistImages", () => {
});
describe("dispatchComposerAgentMessage", () => {
it("forwards the configured active-turn intent without provider capability checks", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "steer this turn",
attachments: [],
encodeImages: async () => [],
submission: stream,
activeTurnBehavior: "steer",
});
expect(client.calls[0]?.options.activeTurnBehavior).toBe("steer");
});
it("stamps only a steer optimistic row with the daemon active turn ID", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "hello",
attachments: [],
encodeImages: async () => [],
submission: stream,
activeTurnBehavior: "steer",
activeTurnId: "turn-1",
});
expect(stream.tail.get("agent")?.[0]).toMatchObject({ turnId: "turn-1" });
const legacy = createFakeStream();
await dispatchComposerAgentMessage({
client,
agentId: "legacy",
text: "hello",
attachments: [],
encodeImages: async () => [],
submission: legacy,
activeTurnBehavior: "steer",
});
expect(legacy.tail.get("legacy")?.[0]?.turnId).toBeUndefined();
});
it("removes the submitted prompt when the host rejects it", async () => {
const rejection = new Error("Host rejected prompt");
const client = createFakeSendClient({ rejection });
@@ -477,6 +522,28 @@ describe("dispatchComposerAgentMessage", () => {
).rejects.toBe(transportError);
});
it("surfaces an ambiguous failure even when a concurrent canonical echo was observed", async () => {
const transportError = new Error("Connection lost after delivery may have occurred");
const client = createFakeSendClient({ rejection: transportError });
const submission: MessageSubmissionWriter = {
begin: () => {},
accept: () => {},
reject: () => "accepted",
};
await expect(
dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "ambiguous steer",
attachments: [],
encodeImages: passthroughEncodeImages,
submission,
activeTurnBehavior: "steer",
}),
).rejects.toBe(transportError);
});
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
+9 -2
View File
@@ -1,4 +1,5 @@
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -48,6 +49,7 @@ export interface ComposerSendClient {
text: string,
options: {
messageId: string;
activeTurnBehavior?: ActiveTurnBehavior;
images: Array<{ data: string; mimeType: string }>;
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
@@ -176,6 +178,8 @@ export interface DispatchComposerAgentMessageInput {
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
submission: MessageSubmissionWriter;
activeTurnBehavior?: ActiveTurnBehavior;
activeTurnId?: string;
}
export async function dispatchComposerAgentMessage(
@@ -191,19 +195,22 @@ export async function dispatchComposerAgentMessage(
timestamp: new Date(),
images: wirePayload.images,
attachments: wirePayload.attachments,
...(input.activeTurnBehavior === "steer" && input.activeTurnId
? { turnId: input.activeTurnId }
: {}),
});
input.submission.begin(input.agentId, userMessage);
try {
const imagesData = await input.encodeImages(wirePayload.images);
await input.client.sendAgentMessage(input.agentId, input.text, {
messageId: clientMessageId,
...(input.activeTurnBehavior ? { activeTurnBehavior: input.activeTurnBehavior } : {}),
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
input.submission.accept(input.agentId, clientMessageId);
} catch (error) {
const outcome = input.submission.reject(input.agentId, clientMessageId);
if (outcome === "accepted") return;
input.submission.reject(input.agentId, clientMessageId);
throw error;
}
}
+28 -11
View File
@@ -1324,12 +1324,6 @@ function ComposerContentImpl({
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
// Clear send error when user edits the input
useEffect(() => {
if (sendError && userInput) {
setSendError(null);
}
}, [userInput, sendError]);
useEffect(() => {
setCursorIndex((current) => Math.min(current, userInput.length));
}, [userInput.length]);
@@ -1338,7 +1332,13 @@ function ComposerContentImpl({
const { pickFiles } = useFilePicker();
const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef<
((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise<void>) | null
| ((
agentId: string,
text: string,
attachments: ComposerAttachment[],
activeTurnBehavior: "interrupt" | "steer",
) => Promise<void>)
| null
>(null);
const onSubmitMessageRef = useRef(onSubmitMessage);
@@ -1399,9 +1399,14 @@ function ComposerContentImpl({
if (!sendAgentMessageRef.current) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments);
await sendAgentMessageRef.current(
agentIdRef.current,
text,
submitAttachments,
appSettings.sendBehavior === "steer" ? "steer" : "interrupt",
);
},
[cwd, onMessageSent, t],
[appSettings.sendBehavior, cwd, onMessageSent, t],
);
useEffect(() => {
@@ -1413,6 +1418,7 @@ function ComposerContentImpl({
targetAgentId: string,
text: string,
sendAttachments: ComposerAttachment[],
activeTurnBehavior: "interrupt" | "steer",
) => {
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
@@ -1427,10 +1433,16 @@ function ComposerContentImpl({
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (useSessionStore.getState().sessions[serverId]?.agents.get(targetAgentId)?.activeTurn
?.turnId ?? undefined)
: undefined,
});
onAttentionPromptSend?.();
};
}, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
}, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
@@ -2185,7 +2197,12 @@ function ComposerContentImpl({
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
const sendErrorNode = useMemo(
() => (sendError ? <Text style={styles.sendErrorText}>{sendError}</Text> : null),
() =>
sendError ? (
<Text accessibilityRole="alert" style={styles.sendErrorText}>
{sendError}
</Text>
) : null,
[sendError],
);
const githubEmptyText = githubSearchResultsQuery.isFetching
+4 -3
View File
@@ -131,7 +131,7 @@ export interface MessageInputProps {
isAgentRunning?: boolean;
/** Controls what the default send action (Enter, send button, dictation) does
* when the agent is running. "interrupt" sends immediately, "queue" queues. */
defaultSendBehavior?: "interrupt" | "queue";
defaultSendBehavior?: "interrupt" | "steer" | "queue";
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
@@ -1002,7 +1002,7 @@ interface SendButtonStateInput {
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
onSubmitLoadingPress: (() => void) | undefined;
defaultSendBehavior: "interrupt" | "queue";
defaultSendBehavior: "interrupt" | "steer" | "queue";
isAgentRunning: boolean;
}
@@ -1052,7 +1052,7 @@ interface ResolvedMessageInputProps {
voiceServerId: string | undefined;
voiceAgentId: string | undefined;
isAgentRunning: boolean;
defaultSendBehavior: "interrupt" | "queue";
defaultSendBehavior: "interrupt" | "steer" | "queue";
onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmitLoadingPress: (() => void) | undefined;
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
@@ -1608,6 +1608,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitButtonAccessibilityLabel,
canPressLoadingButton,
defaultActionQueues,
defaultSendBehavior,
isAgentRunning,
t,
});
@@ -10,6 +10,7 @@ const translations: Record<string, string> = {
"composer.input.interruptAgent": "Interrupt agent",
"composer.input.queueMessage": "Queue message",
"composer.input.sendAndInterrupt": "Send and interrupt",
"composer.input.sendAndSteer": "Send and steer",
"composer.input.sendMessage": "Send message",
"composer.input.queue": "Queue",
"composer.input.send": "Send",
@@ -31,6 +32,7 @@ describe("composer input labels", () => {
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: true,
defaultActionQueues: false,
defaultSendBehavior: "interrupt",
isAgentRunning: true,
t,
}),
@@ -40,6 +42,7 @@ describe("composer input labels", () => {
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: false,
defaultActionQueues: true,
defaultSendBehavior: "queue",
isAgentRunning: true,
t,
}),
@@ -49,6 +52,7 @@ describe("composer input labels", () => {
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: false,
defaultActionQueues: false,
defaultSendBehavior: "interrupt",
isAgentRunning: true,
t,
}),
@@ -58,6 +62,17 @@ describe("composer input labels", () => {
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: false,
defaultActionQueues: false,
defaultSendBehavior: "steer",
isAgentRunning: true,
t,
}),
).toBe("Send and steer");
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: false,
defaultActionQueues: false,
defaultSendBehavior: "interrupt",
isAgentRunning: false,
t,
}),
@@ -70,6 +85,7 @@ describe("composer input labels", () => {
submitButtonAccessibilityLabel: "Run now",
canPressLoadingButton: false,
defaultActionQueues: false,
defaultSendBehavior: "interrupt",
isAgentRunning: false,
t,
}),
+9 -1
View File
@@ -1,16 +1,24 @@
import type { TFunction } from "i18next";
import type { SendBehavior } from "@/hooks/use-settings/storage";
export function resolveSubmitAccessibilityLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
canPressLoadingButton: boolean;
defaultActionQueues: boolean;
defaultSendBehavior: SendBehavior;
isAgentRunning: boolean;
t: TFunction;
}): string {
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
if (input.canPressLoadingButton) return input.t("composer.input.interruptAgent");
if (input.defaultActionQueues) return input.t("composer.input.queueMessage");
if (input.isAgentRunning) return input.t("composer.input.sendAndInterrupt");
if (input.isAgentRunning) {
return input.t(
input.defaultSendBehavior === "steer"
? "composer.input.sendAndSteer"
: "composer.input.sendAndInterrupt",
);
}
return input.t("composer.input.sendMessage");
}
@@ -221,6 +221,29 @@ describe("composer send behavior", () => {
expect(alternateAction.calls).toEqual(["queue"]);
});
it("uses Enter to steer and Mod+Enter to queue when steer is selected", () => {
const defaultAction = actions();
runDefaultSendAction({
defaultSendBehavior: "steer",
isAgentRunning: true,
onQueue: defaultAction.onQueue,
handleSendMessage: defaultAction.handleSendMessage,
handleQueueMessage: defaultAction.handleQueueMessage,
});
const alternateAction = actions();
runAlternateSendAction({
defaultSendBehavior: "steer",
isAgentRunning: true,
onQueue: alternateAction.onQueue,
handleSendMessage: alternateAction.handleSendMessage,
handleQueueMessage: alternateAction.handleQueueMessage,
});
expect(defaultAction.calls).toEqual(["send"]);
expect(alternateAction.calls).toEqual(["queue"]);
});
it("uses Enter to queue and Mod+Enter to submit when queue is selected", () => {
const defaultAction = actions();
runDefaultSendAction({
+2 -1
View File
@@ -1,8 +1,9 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { MessagePayload } from "@/composer/types";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export type SendBehavior = "interrupt" | "queue";
export type SendBehavior = ActiveTurnBehavior | "queue";
interface ComposerSurfaceState {
opacity: 0 | 1;
+33
View File
@@ -184,6 +184,39 @@ describe("submitAgentInput", () => {
expect(clearDraft).not.toHaveBeenCalled();
});
it("restores a steered active-turn draft after an ambiguous immediate-send error", async () => {
const error = new Error("connection lost after delivery");
const setUserInput = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
await expect(
submitAgentInput({
message: " steer this turn ",
attachments: [{ id: "img-1" }],
forceSend: true,
isAgentRunning: true,
canSubmit: true,
queueMessage: vi.fn(),
submitMessage: async () => {
throw error;
},
clearDraft: vi.fn(),
setUserInput,
setAttachments,
setSendError,
setIsProcessing,
}),
).resolves.toBe("failed");
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
expect(setUserInput).toHaveBeenNthCalledWith(2, "steer this turn");
expect(setAttachments).toHaveBeenNthCalledWith(1, []);
expect(setAttachments).toHaveBeenNthCalledWith(2, [{ id: "img-1" }]);
expect(setSendError).toHaveBeenLastCalledWith("connection lost after delivery");
});
it("submits when empty submit is explicitly allowed", async () => {
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {});