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