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