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();