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();
|
||||
|
||||
Reference in New Issue
Block a user