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
@@ -0,0 +1,87 @@
import {
AgentStreamEventPayloadSchema,
AgentTimelineEntryPayloadSchema,
SendAgentMessageRequestSchema,
} from "./messages";
import { describe, expect, it } from "vitest";
import { z } from "zod";
describe("send_agent_message_request active-turn behavior", () => {
it("accepts an optional steer intent while retaining interrupt compatibility", () => {
expect(
SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "request-1",
agentId: "agent-1",
text: "Follow this instruction",
activeTurnBehavior: "steer",
}).activeTurnBehavior,
).toBe("steer");
expect(
SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "request-2",
agentId: "agent-1",
text: "Keep the old behavior",
}).activeTurnBehavior,
).toBeUndefined();
});
});
describe("canonical timeline turn ID compatibility", () => {
it("accepts both new turn-tagged wire rows and legacy rows without IDs", () => {
const entry = {
provider: "codex" as const,
item: { type: "assistant_message" as const, text: "done" },
timestamp: "2026-01-01T00:00:00.000Z",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
};
expect(AgentTimelineEntryPayloadSchema.parse(entry).turnId).toBeUndefined();
expect(AgentTimelineEntryPayloadSchema.parse({ ...entry, turnId: "turn-1" }).turnId).toBe(
"turn-1",
);
const timeline = {
type: "timeline" as const,
provider: "codex" as const,
item: { type: "assistant_message" as const, text: "done" },
};
expect(AgentStreamEventPayloadSchema.parse(timeline).turnId).toBeUndefined();
expect(AgentStreamEventPayloadSchema.parse({ ...timeline, turnId: "turn-1" }).turnId).toBe(
"turn-1",
);
});
});
describe("legacy daemon send request schema compatibility", () => {
const LegacySendAgentMessageRequestSchema = z.object({
type: z.literal("send_agent_message_request"),
requestId: z.string(),
agentId: z.string(),
text: z.string(),
messageId: z.string().optional(),
});
it("ignores a new client's active-turn intent and retains legacy interrupt dispatch", () => {
const newClientRequest = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "request-legacy",
agentId: "agent-1",
text: "replace the turn",
activeTurnBehavior: "steer",
});
const legacyRequest = LegacySendAgentMessageRequestSchema.parse(newClientRequest);
expect(legacyRequest).toEqual({
type: "send_agent_message_request",
requestId: "request-legacy",
agentId: "agent-1",
text: "replace the turn",
});
expect("activeTurnBehavior" in legacyRequest).toBe(false);
});
});
+7
View File
@@ -748,6 +748,7 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [
type: z.literal("timeline"),
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
turnId: z.string().optional(),
}),
z.object({
type: z.literal("permission_requested"),
@@ -1138,11 +1139,15 @@ const ImageAttachmentSchema = z.object({
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
});
export const ActiveTurnBehaviorSchema = z.enum(["interrupt", "steer"]);
export type ActiveTurnBehavior = z.infer<typeof ActiveTurnBehaviorSchema>;
export const SendAgentMessageSchema = z.object({
type: z.literal("send_agent_message"),
agentId: z.string(),
text: z.string(),
messageId: z.string().optional(), // Client-provided ID for deduplication
activeTurnBehavior: ActiveTurnBehaviorSchema.optional(),
images: z.array(ImageAttachmentSchema).optional(),
attachments: AgentAttachmentsSchema,
});
@@ -1295,6 +1300,7 @@ export const SendAgentMessageRequestSchema = z.object({
agentId: z.string(),
text: z.string(),
messageId: z.string().optional(), // Client-provided ID for deduplication
activeTurnBehavior: ActiveTurnBehaviorSchema.optional(),
images: z.array(ImageAttachmentSchema).optional(),
attachments: AgentAttachmentsSchema,
});
@@ -4035,6 +4041,7 @@ const AgentTimelineSeqRangeSchema = z.object({
export const AgentTimelineEntryPayloadSchema = z.object({
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
turnId: z.string().optional(),
timestamp: z.string(),
seqStart: z.number().int().nonnegative(),
seqEnd: z.number().int().nonnegative(),