mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1278aa856 | ||
|
|
091350544d | ||
|
|
bc825af843 |
@@ -41,7 +41,6 @@ import { PlanCard } from "@/components/plan-card";
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type {
|
import type {
|
||||||
AgentPlanAction,
|
|
||||||
AgentPermissionAction,
|
AgentPermissionAction,
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
} from "@getpaseo/protocol/agent-types";
|
} from "@getpaseo/protocol/agent-types";
|
||||||
@@ -114,86 +113,6 @@ function renderPendingPermissionsNode(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlanTimelineCard({
|
|
||||||
item,
|
|
||||||
agentId,
|
|
||||||
client,
|
|
||||||
}: {
|
|
||||||
item: Extract<StreamItem, { kind: "plan" }>;
|
|
||||||
agentId: string;
|
|
||||||
client: DaemonClient | null;
|
|
||||||
}) {
|
|
||||||
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
|
||||||
const respondToPlan = useMutation({
|
|
||||||
mutationFn: async (action: AgentPlanAction) => {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error("No daemon connection");
|
|
||||||
}
|
|
||||||
setRespondingActionId(action.id);
|
|
||||||
const result = await client.respondToPlan(agentId, item.planId, { actionId: action.id });
|
|
||||||
if (!result.ok) {
|
|
||||||
throw new Error(result.error ?? "Failed to respond to plan");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettled: () => setRespondingActionId(null),
|
|
||||||
});
|
|
||||||
|
|
||||||
const actions = item.actions ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<PlanCard title="Plan" text={item.text} testID="timeline-plan-card" />
|
|
||||||
{actions.length > 0 ? (
|
|
||||||
<View style={permissionStyles.optionsContainer}>
|
|
||||||
{actions.map((action) => (
|
|
||||||
<PlanActionButton
|
|
||||||
key={action.id}
|
|
||||||
action={action}
|
|
||||||
respondingActionId={respondingActionId}
|
|
||||||
isResponding={respondToPlan.isPending}
|
|
||||||
onRespond={respondToPlan.mutate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlanActionButton({
|
|
||||||
action,
|
|
||||||
respondingActionId,
|
|
||||||
isResponding,
|
|
||||||
onRespond,
|
|
||||||
}: {
|
|
||||||
action: AgentPlanAction;
|
|
||||||
respondingActionId: string | null;
|
|
||||||
isResponding: boolean;
|
|
||||||
onRespond: (action: AgentPlanAction) => void;
|
|
||||||
}) {
|
|
||||||
const Icon = action.variant === "danger" ? ThemedXIcon : ThemedCheckIcon;
|
|
||||||
const permissionAction = useMemo<AgentPermissionAction>(
|
|
||||||
() => ({
|
|
||||||
...action,
|
|
||||||
behavior: action.variant === "danger" ? "deny" : "allow",
|
|
||||||
}),
|
|
||||||
[action],
|
|
||||||
);
|
|
||||||
const handlePress = useCallback(() => onRespond(action), [action, onRespond]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PermissionActionButton
|
|
||||||
action={permissionAction}
|
|
||||||
isRespondingAction={respondingActionId === action.id}
|
|
||||||
isResponding={isResponding}
|
|
||||||
isPrimary={action.variant === "primary"}
|
|
||||||
Icon={Icon}
|
|
||||||
testID={`plan-action-${action.id}`}
|
|
||||||
onPress={handlePress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStreamItemWithTurnFooter(input: {
|
function renderStreamItemWithTurnFooter(input: {
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
layoutItem: StreamLayoutItem;
|
layoutItem: StreamLayoutItem;
|
||||||
@@ -504,6 +423,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
client={client}
|
client={client}
|
||||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||||
|
deliveryHint={item.deliveryHint}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -630,9 +550,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
case "todo_list":
|
case "todo_list":
|
||||||
return <TodoListCard items={item.items} />;
|
return <TodoListCard items={item.items} />;
|
||||||
|
|
||||||
case "plan":
|
|
||||||
return <PlanTimelineCard item={item} agentId={agentId} client={client} />;
|
|
||||||
|
|
||||||
case "compaction":
|
case "compaction":
|
||||||
return (
|
return (
|
||||||
<CompactionMarker
|
<CompactionMarker
|
||||||
@@ -646,14 +563,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
|
||||||
agentId,
|
|
||||||
client,
|
|
||||||
renderUserMessageItem,
|
|
||||||
renderAssistantMessageItem,
|
|
||||||
renderThoughtItem,
|
|
||||||
renderToolCallItem,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
|
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ import Animated, {
|
|||||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
import type {
|
||||||
|
TodoEntry,
|
||||||
|
UserMessageDeliveryHint,
|
||||||
|
UserMessageImageAttachment,
|
||||||
|
} from "@/types/stream";
|
||||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||||
@@ -124,6 +128,7 @@ interface UserMessageProps {
|
|||||||
isFirstInGroup?: boolean;
|
isFirstInGroup?: boolean;
|
||||||
isLastInGroup?: boolean;
|
isLastInGroup?: boolean;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
|
deliveryHint?: UserMessageDeliveryHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageOuterSpacingContext = createContext(false);
|
const MessageOuterSpacingContext = createContext(false);
|
||||||
@@ -434,6 +439,12 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||||
},
|
},
|
||||||
|
deliveryHintLabel: {
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
|
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
|
||||||
@@ -475,6 +486,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
isFirstInGroup = true,
|
isFirstInGroup = true,
|
||||||
isLastInGroup = true,
|
isLastInGroup = true,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
|
deliveryHint,
|
||||||
}: UserMessageProps) {
|
}: UserMessageProps) {
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@@ -589,6 +601,14 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
{deliveryHint === "steering" ? (
|
||||||
|
<Text
|
||||||
|
accessibilityLabel="Steering conversation"
|
||||||
|
style={userMessageStylesheet.deliveryHintLabel}
|
||||||
|
>
|
||||||
|
Steering conversation
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -417,6 +417,44 @@ describe("dispatchComposerAgentMessage", () => {
|
|||||||
expect(client.calls[0]?.options.images).toEqual([]);
|
expect(client.calls[0]?.options.images).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches a steering deliveryHint to the optimistic user_message when provided", async () => {
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
const stream = createFakeStream();
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "steer this",
|
||||||
|
attachments: [],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
deliveryHint: "steering",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tail = stream.tail.get("agent");
|
||||||
|
expect(tail).toHaveLength(1);
|
||||||
|
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||||
|
expect(userMessage.deliveryHint).toBe("steering");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits deliveryHint when none is supplied (no marker for non-steering sends)", async () => {
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
const stream = createFakeStream();
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "no steering",
|
||||||
|
attachments: [],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tail = stream.tail.get("agent");
|
||||||
|
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||||
|
expect(userMessage.deliveryHint).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
|
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
|
||||||
const client = createFakeSendClient();
|
const client = createFakeSendClient();
|
||||||
const stream = createFakeStream();
|
const stream = createFakeStream();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
buildOptimisticUserMessage,
|
buildOptimisticUserMessage,
|
||||||
generateMessageId,
|
generateMessageId,
|
||||||
type StreamItem,
|
type StreamItem,
|
||||||
|
type UserMessageDeliveryHint,
|
||||||
type UserMessageItem,
|
type UserMessageItem,
|
||||||
} from "@/types/stream";
|
} from "@/types/stream";
|
||||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||||
@@ -130,6 +131,7 @@ export interface DispatchComposerAgentMessageInput {
|
|||||||
images: AttachmentMetadata[],
|
images: AttachmentMetadata[],
|
||||||
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||||
stream: AgentStreamWriter;
|
stream: AgentStreamWriter;
|
||||||
|
deliveryHint?: UserMessageDeliveryHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dispatchComposerAgentMessage(
|
export async function dispatchComposerAgentMessage(
|
||||||
@@ -143,6 +145,7 @@ export async function dispatchComposerAgentMessage(
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
images: wirePayload.images,
|
images: wirePayload.images,
|
||||||
attachments: wirePayload.attachments,
|
attachments: wirePayload.attachments,
|
||||||
|
deliveryHint: input.deliveryHint,
|
||||||
});
|
});
|
||||||
appendUserMessageToStream(input.agentId, userMessage, input.stream);
|
appendUserMessageToStream(input.agentId, userMessage, input.stream);
|
||||||
const imagesData = await input.encodeImages(wirePayload.images);
|
const imagesData = await input.encodeImages(wirePayload.images);
|
||||||
|
|||||||
@@ -1070,6 +1070,9 @@ export function Composer({
|
|||||||
setHead: (updater) => setAgentStreamHead(serverId, updater),
|
setHead: (updater) => setAgentStreamHead(serverId, updater),
|
||||||
setTail: (updater) => setAgentStreamTail(serverId, updater),
|
setTail: (updater) => setAgentStreamTail(serverId, updater),
|
||||||
};
|
};
|
||||||
|
const targetAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(targetAgentId);
|
||||||
|
const isSteering =
|
||||||
|
targetAgent?.status === "running" && targetAgent.capabilities.supportsSteering === true;
|
||||||
await dispatchComposerAgentMessage({
|
await dispatchComposerAgentMessage({
|
||||||
client,
|
client,
|
||||||
agentId: targetAgentId,
|
agentId: targetAgentId,
|
||||||
@@ -1077,6 +1080,7 @@ export function Composer({
|
|||||||
attachments: sendAttachments,
|
attachments: sendAttachments,
|
||||||
encodeImages,
|
encodeImages,
|
||||||
stream,
|
stream,
|
||||||
|
...(isSteering ? { deliveryHint: "steering" as const } : {}),
|
||||||
});
|
});
|
||||||
onAttentionPromptSend?.();
|
onAttentionPromptSend?.();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1700,6 +1700,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
attachments?: AgentAttachment[],
|
attachments?: AgentAttachment[],
|
||||||
) => {
|
) => {
|
||||||
const messageId = generateMessageId();
|
const messageId = generateMessageId();
|
||||||
|
const targetAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
|
||||||
|
const isSteering =
|
||||||
|
targetAgent?.status === "running" && targetAgent.capabilities.supportsSteering === true;
|
||||||
const userMessage: StreamItem = {
|
const userMessage: StreamItem = {
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
id: messageId,
|
id: messageId,
|
||||||
@@ -1708,6 +1711,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
optimistic: true,
|
optimistic: true,
|
||||||
...(images && images.length > 0 ? { images } : {}),
|
...(images && images.length > 0 ? { images } : {}),
|
||||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||||
|
...(isSteering ? { deliveryHint: "steering" as const } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Append to head if streaming (keeps the user message with the current
|
// Append to head if streaming (keeps the user message with the current
|
||||||
|
|||||||
@@ -99,19 +99,6 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function planTimeline(): AgentStreamEventPayload {
|
|
||||||
return {
|
|
||||||
type: "timeline",
|
|
||||||
provider: "codex",
|
|
||||||
item: {
|
|
||||||
type: "plan",
|
|
||||||
planId: "plan-1",
|
|
||||||
text: "# Plan\n\n- Implement it",
|
|
||||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined {
|
function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined {
|
||||||
return state.find(
|
return state.find(
|
||||||
(item): item is AgentToolCallItem =>
|
(item): item is AgentToolCallItem =>
|
||||||
@@ -706,26 +693,6 @@ describe("stream reducer canonical tool calls", () => {
|
|||||||
assert.strictEqual(todos.items[1]?.completed, true);
|
assert.strictEqual(todos.items[1]?.completed, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts plan timeline updates to plan items", () => {
|
|
||||||
const state = hydrateStreamState([
|
|
||||||
{
|
|
||||||
event: planTimeline(),
|
|
||||||
timestamp: new Date("2025-01-01T10:55:00Z"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const plan = state.find(
|
|
||||||
(item): item is Extract<StreamItem, { kind: "plan" }> => item.kind === "plan",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(plan);
|
|
||||||
assert.strictEqual(plan.planId, "plan-1");
|
|
||||||
assert.strictEqual(plan.text, "# Plan\n\n- Implement it");
|
|
||||||
assert.deepStrictEqual(plan.actions, [
|
|
||||||
{ id: "implement", label: "Implement", variant: "primary" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => {
|
it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => {
|
||||||
const state = hydrateStreamState([
|
const state = hydrateStreamState([
|
||||||
{
|
{
|
||||||
@@ -798,6 +765,52 @@ describe("stream reducer canonical tool calls", () => {
|
|||||||
assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime());
|
assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves optimistic steering deliveryHint when authoritative user message arrives", () => {
|
||||||
|
const messageId = "msg-user-steering";
|
||||||
|
const initialState: StreamItem[] = [
|
||||||
|
{
|
||||||
|
kind: "user_message",
|
||||||
|
id: messageId,
|
||||||
|
text: "Steer to a different approach",
|
||||||
|
timestamp: new Date("2025-01-01T11:10:00Z"),
|
||||||
|
deliveryHint: "steering",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const event: AgentStreamEventPayload = {
|
||||||
|
type: "timeline",
|
||||||
|
provider: "claude",
|
||||||
|
item: {
|
||||||
|
type: "user_message",
|
||||||
|
text: "Steer to a different approach",
|
||||||
|
messageId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = reduceStreamUpdate(initialState, event, new Date("2025-01-01T11:10:01Z"));
|
||||||
|
const message = state.find((item) => item.kind === "user_message");
|
||||||
|
|
||||||
|
assert.ok(message);
|
||||||
|
assert.strictEqual(message.deliveryHint, "steering");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invent a deliveryHint for canonical user messages without prior optimistic state", () => {
|
||||||
|
const event: AgentStreamEventPayload = {
|
||||||
|
type: "timeline",
|
||||||
|
provider: "claude",
|
||||||
|
item: {
|
||||||
|
type: "user_message",
|
||||||
|
text: "Hello there",
|
||||||
|
messageId: "canonical-only",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = reduceStreamUpdate([], event, new Date("2025-01-01T11:11:00Z"));
|
||||||
|
const message = state.find((item) => item.kind === "user_message");
|
||||||
|
|
||||||
|
assert.ok(message);
|
||||||
|
assert.strictEqual(message.deliveryHint, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps canonical assistant/user/assistant order during replay", () => {
|
it("keeps canonical assistant/user/assistant order during replay", () => {
|
||||||
const state: StreamItem[] = [
|
const state: StreamItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||||
AgentPlanAction,
|
|
||||||
AgentProvider,
|
|
||||||
ToolCallDetail,
|
|
||||||
} from "@getpaseo/protocol/agent-types";
|
|
||||||
import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
|
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
|
||||||
@@ -52,13 +48,14 @@ export type StreamItem =
|
|||||||
| AssistantMessageItem
|
| AssistantMessageItem
|
||||||
| ThoughtItem
|
| ThoughtItem
|
||||||
| ToolCallItem
|
| ToolCallItem
|
||||||
| PlanItem
|
|
||||||
| TodoListItem
|
| TodoListItem
|
||||||
| ActivityLogItem
|
| ActivityLogItem
|
||||||
| CompactionItem;
|
| CompactionItem;
|
||||||
|
|
||||||
export type UserMessageImageAttachment = AttachmentMetadata;
|
export type UserMessageImageAttachment = AttachmentMetadata;
|
||||||
|
|
||||||
|
export type UserMessageDeliveryHint = "steering";
|
||||||
|
|
||||||
export interface UserMessageItem {
|
export interface UserMessageItem {
|
||||||
kind: "user_message";
|
kind: "user_message";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -67,6 +64,13 @@ export interface UserMessageItem {
|
|||||||
optimistic?: true;
|
optimistic?: true;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
attachments?: AgentAttachment[];
|
attachments?: AgentAttachment[];
|
||||||
|
/**
|
||||||
|
* Local-only marker set when this user message was sent optimistically
|
||||||
|
* while the agent was running and the provider supports steering. Never
|
||||||
|
* sent or received over the wire — preserved across canonical replays so
|
||||||
|
* the UI can keep showing the marker.
|
||||||
|
*/
|
||||||
|
deliveryHint?: UserMessageDeliveryHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptimisticUserMessageInput {
|
export interface OptimisticUserMessageInput {
|
||||||
@@ -75,6 +79,7 @@ export interface OptimisticUserMessageInput {
|
|||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
attachments?: AgentAttachment[];
|
attachments?: AgentAttachment[];
|
||||||
|
deliveryHint?: UserMessageDeliveryHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OptimisticUserMessagePlacement = "tail" | "active-head";
|
export type OptimisticUserMessagePlacement = "tail" | "active-head";
|
||||||
@@ -173,16 +178,6 @@ export interface TodoListItem {
|
|||||||
items: TodoEntry[];
|
items: TodoEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanItem {
|
|
||||||
kind: "plan";
|
|
||||||
id: string;
|
|
||||||
timestamp: Date;
|
|
||||||
provider: AgentProvider;
|
|
||||||
planId: string;
|
|
||||||
text: string;
|
|
||||||
actions?: AgentPlanAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StreamUpdateSource = "live" | "canonical";
|
export type StreamUpdateSource = "live" | "canonical";
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -228,6 +223,7 @@ function buildUserMessageItem(input: {
|
|||||||
...(input.optimistic.attachments && input.optimistic.attachments.length > 0
|
...(input.optimistic.attachments && input.optimistic.attachments.length > 0
|
||||||
? { attachments: input.optimistic.attachments }
|
? { attachments: input.optimistic.attachments }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(input.optimistic.deliveryHint ? { deliveryHint: input.optimistic.deliveryHint } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +246,7 @@ export function buildOptimisticUserMessage(input: OptimisticUserMessageInput): U
|
|||||||
...(input.attachments && input.attachments.length > 0
|
...(input.attachments && input.attachments.length > 0
|
||||||
? { attachments: input.attachments }
|
? { attachments: input.attachments }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(input.deliveryHint ? { deliveryHint: input.deliveryHint } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,34 +665,6 @@ function appendTodoList(
|
|||||||
return [...state, entry];
|
return [...state, entry];
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendPlan(
|
|
||||||
state: StreamItem[],
|
|
||||||
provider: AgentProvider,
|
|
||||||
plan: { planId: string; text: string; actions?: AgentPlanAction[] },
|
|
||||||
timestamp: Date,
|
|
||||||
): StreamItem[] {
|
|
||||||
const existingIndex = state.findIndex(
|
|
||||||
(item) => item.kind === "plan" && item.provider === provider && item.planId === plan.planId,
|
|
||||||
);
|
|
||||||
const entry: PlanItem = {
|
|
||||||
kind: "plan",
|
|
||||||
id: `plan_${plan.planId}`,
|
|
||||||
timestamp,
|
|
||||||
provider,
|
|
||||||
planId: plan.planId,
|
|
||||||
text: plan.text,
|
|
||||||
actions: plan.actions,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
const next = [...state];
|
|
||||||
next[existingIndex] = entry;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...state, entry];
|
|
||||||
}
|
|
||||||
|
|
||||||
function reduceTimelineToolCall(
|
function reduceTimelineToolCall(
|
||||||
state: StreamItem[],
|
state: StreamItem[],
|
||||||
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
||||||
@@ -817,8 +786,6 @@ function reduceTimelineEvent(
|
|||||||
}));
|
}));
|
||||||
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
|
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
|
||||||
}
|
}
|
||||||
case "plan":
|
|
||||||
return finalizeActiveThoughts(appendPlan(state, event.provider, item, timestamp));
|
|
||||||
case "error": {
|
case "error": {
|
||||||
const activity: ActivityLogItem = {
|
const activity: ActivityLogItem = {
|
||||||
kind: "activity_log",
|
kind: "activity_log",
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ test("advertises client capabilities in hello", async () => {
|
|||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
custom_mode_icons: true,
|
custom_mode_icons: true,
|
||||||
first_class_plans: true,
|
|
||||||
reasoning_merge_enum: true,
|
reasoning_merge_enum: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentPlanResponse,
|
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
@@ -364,10 +363,6 @@ type DictationFinishAcceptedPayload = Extract<
|
|||||||
{ type: "dictation_stream_finish_accepted" }
|
{ type: "dictation_stream_finish_accepted" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
||||||
type AgentPlanRespondPayload = Extract<
|
|
||||||
SessionOutboundMessage,
|
|
||||||
{ type: "agent.plan.respond.response" }
|
|
||||||
>["payload"];
|
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
||||||
export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"];
|
export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"];
|
||||||
@@ -3618,38 +3613,6 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async respondToPlan(
|
|
||||||
agentId: string,
|
|
||||||
planId: string,
|
|
||||||
response: AgentPlanResponse,
|
|
||||||
requestId = `plan-response-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
timeout = 15000,
|
|
||||||
): Promise<AgentPlanRespondPayload> {
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "agent.plan.respond.request",
|
|
||||||
agentId,
|
|
||||||
planId,
|
|
||||||
actionId: response.actionId,
|
|
||||||
...(response.feedback !== undefined ? { feedback: response.feedback } : {}),
|
|
||||||
requestId,
|
|
||||||
});
|
|
||||||
return this.sendRequest({
|
|
||||||
requestId,
|
|
||||||
message,
|
|
||||||
timeout,
|
|
||||||
options: { skipQueue: true },
|
|
||||||
select: (msg) => {
|
|
||||||
if (msg.type !== "agent.plan.respond.response") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (msg.payload.requestId !== requestId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return msg.payload;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Waiting / Streaming Helpers
|
// Waiting / Streaming Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -4315,7 +4278,6 @@ export class DaemonClient {
|
|||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
[CLIENT_CAPS.customModeIcons]: true,
|
[CLIENT_CAPS.customModeIcons]: true,
|
||||||
[CLIENT_CAPS.firstClassPlans]: true,
|
|
||||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||||
},
|
},
|
||||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ function createAgent(input: Partial<PaseoAgent> = {}): PaseoAgent {
|
|||||||
supportsRewindBoth: false,
|
supportsRewindBoth: false,
|
||||||
supportsRewindConversation: false,
|
supportsRewindConversation: false,
|
||||||
supportsRewindFiles: false,
|
supportsRewindFiles: false,
|
||||||
|
supportsSteering: false,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export interface AgentCapabilityFlags {
|
|||||||
supportsRewindConversation?: boolean;
|
supportsRewindConversation?: boolean;
|
||||||
supportsRewindFiles?: boolean;
|
supportsRewindFiles?: boolean;
|
||||||
supportsRewindBoth?: boolean;
|
supportsRewindBoth?: boolean;
|
||||||
|
supportsSteering?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentPersistenceHandle {
|
export interface AgentPersistenceHandle {
|
||||||
@@ -313,32 +314,12 @@ export interface CompactionTimelineItem {
|
|||||||
preTokens?: number;
|
preTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentPlanAction {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
variant?: "primary" | "secondary" | "danger";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlanTimelineItem {
|
|
||||||
[key: string]: unknown;
|
|
||||||
type: "plan";
|
|
||||||
planId: string;
|
|
||||||
text: string;
|
|
||||||
actions?: AgentPlanAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentPlanResponse {
|
|
||||||
actionId: string;
|
|
||||||
feedback?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentTimelineItem =
|
export type AgentTimelineItem =
|
||||||
| { type: "user_message"; text: string; messageId?: string }
|
| { type: "user_message"; text: string; messageId?: string }
|
||||||
| { type: "assistant_message"; text: string; messageId?: string }
|
| { type: "assistant_message"; text: string; messageId?: string }
|
||||||
| { type: "reasoning"; text: string }
|
| { type: "reasoning"; text: string }
|
||||||
| ToolCallTimelineItem
|
| ToolCallTimelineItem
|
||||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
||||||
| PlanTimelineItem
|
|
||||||
| { type: "error"; message: string }
|
| { type: "error"; message: string }
|
||||||
| CompactionTimelineItem;
|
| CompactionTimelineItem;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export const CLIENT_CAPS = {
|
export const CLIENT_CAPS = {
|
||||||
firstClassPlans: "first_class_plans",
|
|
||||||
reasoningMergeEnum: "reasoning_merge_enum",
|
reasoningMergeEnum: "reasoning_merge_enum",
|
||||||
// COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to
|
// COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to
|
||||||
// a closed enum and crash rendering unknown values; daemon downgrades icons
|
// a closed enum and crash rendering unknown values; daemon downgrades icons
|
||||||
|
|||||||
@@ -179,13 +179,11 @@ describe("checkout PR schemas", () => {
|
|||||||
features: {
|
features: {
|
||||||
providersSnapshot: true,
|
providersSnapshot: true,
|
||||||
checkoutGithubSetAutoMerge: true,
|
checkoutGithubSetAutoMerge: true,
|
||||||
firstClassPlans: true,
|
|
||||||
},
|
},
|
||||||
}).features,
|
}).features,
|
||||||
).toEqual({
|
).toEqual({
|
||||||
providersSnapshot: true,
|
providersSnapshot: true,
|
||||||
checkoutGithubSetAutoMerge: true,
|
checkoutGithubSetAutoMerge: true,
|
||||||
firstClassPlans: true,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
AgentSnapshotPayloadSchema,
|
||||||
GetProvidersSnapshotResponseMessageSchema,
|
GetProvidersSnapshotResponseMessageSchema,
|
||||||
ProviderSnapshotEntrySchema,
|
ProviderSnapshotEntrySchema,
|
||||||
ProvidersSnapshotUpdateMessageSchema,
|
ProvidersSnapshotUpdateMessageSchema,
|
||||||
@@ -16,6 +17,34 @@ describe("provider snapshot message schemas", () => {
|
|||||||
expect(parsed.enabled).toBe(true);
|
expect(parsed.enabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("defaults missing agent capability steering support to false", () => {
|
||||||
|
const parsed = AgentSnapshotPayloadSchema.parse({
|
||||||
|
id: "agent-1",
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
model: null,
|
||||||
|
createdAt: "2026-04-24T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-24T00:00:00.000Z",
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
status: "idle",
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
title: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.capabilities.supportsSteering).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("preserves disabled provider snapshot entries", () => {
|
test("preserves disabled provider snapshot entries", () => {
|
||||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ import type {
|
|||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentPlanAction,
|
|
||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
AgentRuntimeInfo,
|
AgentRuntimeInfo,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
@@ -252,6 +251,8 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
|
|||||||
supportsRewindFiles: z.boolean().optional().default(false),
|
supportsRewindFiles: z.boolean().optional().default(false),
|
||||||
// COMPAT(rewind): added in v0.1.X, drop when floor >= v0.1.X.
|
// COMPAT(rewind): added in v0.1.X, drop when floor >= v0.1.X.
|
||||||
supportsRewindBoth: z.boolean().optional().default(false),
|
supportsRewindBoth: z.boolean().optional().default(false),
|
||||||
|
// COMPAT(steering): added in v0.1.X, drop when floor >= v0.1.X.
|
||||||
|
supportsSteering: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
||||||
@@ -335,12 +336,6 @@ export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const AgentPlanActionSchema: z.ZodType<AgentPlanAction> = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
label: z.string(),
|
|
||||||
variant: z.enum(["primary", "secondary", "danger"]).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AgentPermissionRequestPayloadSchema: z.ZodType<
|
export const AgentPermissionRequestPayloadSchema: z.ZodType<
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
z.ZodTypeDef,
|
z.ZodTypeDef,
|
||||||
@@ -556,12 +551,6 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, z.ZodT
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
z.object({
|
|
||||||
type: z.literal("plan"),
|
|
||||||
planId: z.string(),
|
|
||||||
text: z.string(),
|
|
||||||
actions: z.array(AgentPlanActionSchema).optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("error"),
|
type: z.literal("error"),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
@@ -1337,15 +1326,6 @@ export const AgentPermissionResponseMessageSchema = z.object({
|
|||||||
response: AgentPermissionResponseSchema,
|
response: AgentPermissionResponseSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AgentPlanRespondRequestMessageSchema = z.object({
|
|
||||||
type: z.literal("agent.plan.respond.request"),
|
|
||||||
agentId: z.string(),
|
|
||||||
planId: z.string(),
|
|
||||||
actionId: z.string(),
|
|
||||||
feedback: z.string().optional(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const CheckoutErrorCodeSchema = z.enum([
|
const CheckoutErrorCodeSchema = z.enum([
|
||||||
"NOT_GIT_REPO",
|
"NOT_GIT_REPO",
|
||||||
"NOT_ALLOWED",
|
"NOT_ALLOWED",
|
||||||
@@ -1923,7 +1903,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetAgentFeatureRequestMessageSchema,
|
SetAgentFeatureRequestMessageSchema,
|
||||||
AgentRewindRequestMessageSchema,
|
AgentRewindRequestMessageSchema,
|
||||||
AgentPermissionResponseMessageSchema,
|
AgentPermissionResponseMessageSchema,
|
||||||
AgentPlanRespondRequestMessageSchema,
|
|
||||||
CheckoutStatusRequestSchema,
|
CheckoutStatusRequestSchema,
|
||||||
SubscribeCheckoutDiffRequestSchema,
|
SubscribeCheckoutDiffRequestSchema,
|
||||||
UnsubscribeCheckoutDiffRequestSchema,
|
UnsubscribeCheckoutDiffRequestSchema,
|
||||||
@@ -2161,8 +2140,6 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
.object({
|
.object({
|
||||||
providersSnapshot: z.boolean().optional(),
|
providersSnapshot: z.boolean().optional(),
|
||||||
checkoutGithubSetAutoMerge: z.boolean().optional(),
|
checkoutGithubSetAutoMerge: z.boolean().optional(),
|
||||||
// COMPAT(firstClassPlans): added in v0.1.82, remove gate after 2026-11-28.
|
|
||||||
firstClassPlans: z.boolean().optional(),
|
|
||||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||||
daemonStatusRpc: z.boolean().optional(),
|
daemonStatusRpc: z.boolean().optional(),
|
||||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||||
@@ -2729,17 +2706,6 @@ export const SendAgentMessageResponseMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AgentPlanRespondResponseMessageSchema = z.object({
|
|
||||||
type: z.literal("agent.plan.respond.response"),
|
|
||||||
payload: z.object({
|
|
||||||
requestId: z.string(),
|
|
||||||
agentId: z.string(),
|
|
||||||
planId: z.string(),
|
|
||||||
ok: z.boolean(),
|
|
||||||
error: z.string().nullable().optional(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const WaitForFinishResponseMessageSchema = z.object({
|
export const WaitForFinishResponseMessageSchema = z.object({
|
||||||
type: z.literal("wait_for_finish_response"),
|
type: z.literal("wait_for_finish_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -3729,7 +3695,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CancelAgentResponseMessageSchema,
|
CancelAgentResponseMessageSchema,
|
||||||
ClearAgentAttentionResponseMessageSchema,
|
ClearAgentAttentionResponseMessageSchema,
|
||||||
SendAgentMessageResponseMessageSchema,
|
SendAgentMessageResponseMessageSchema,
|
||||||
AgentPlanRespondResponseMessageSchema,
|
|
||||||
SetVoiceModeResponseMessageSchema,
|
SetVoiceModeResponseMessageSchema,
|
||||||
DaemonGetStatusResponseSchema,
|
DaemonGetStatusResponseSchema,
|
||||||
DaemonGetPairingOfferResponseSchema,
|
DaemonGetPairingOfferResponseSchema,
|
||||||
@@ -4143,7 +4108,6 @@ export const WSHelloMessageSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
voice: z.boolean().optional(),
|
voice: z.boolean().optional(),
|
||||||
pushNotifications: z.boolean().optional(),
|
pushNotifications: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.firstClassPlans]: z.boolean().optional(),
|
|
||||||
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
|
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
|
AgentPromptInput,
|
||||||
AgentRunResult,
|
AgentRunResult,
|
||||||
AgentSession,
|
AgentSession,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
@@ -3010,6 +3011,314 @@ test("replaceAgentRun does not emit idle or resolve waiters between interrupted
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("startAgentRun applies the replace-vs-stream policy in AgentManager", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-start-policy-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000126",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const replacementEvents = (async function* replacementEvents() {})();
|
||||||
|
const streamEvents = (async function* streamEvents() {})();
|
||||||
|
const hasInFlightRunSpy = vi.spyOn(manager, "hasInFlightRun").mockReturnValue(true);
|
||||||
|
const replaceAgentRunSpy = vi
|
||||||
|
.spyOn(manager, "replaceAgentRun")
|
||||||
|
.mockReturnValue(replacementEvents);
|
||||||
|
const streamAgentSpy = vi.spyOn(manager, "streamAgent").mockReturnValue(streamEvents);
|
||||||
|
|
||||||
|
const replacement = manager.startAgentRun(snapshot.id, "replace me", {
|
||||||
|
replaceRunning: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replacement).toEqual({ outOfBand: false, events: replacementEvents });
|
||||||
|
expect(hasInFlightRunSpy).toHaveBeenCalledWith(snapshot.id);
|
||||||
|
expect(replaceAgentRunSpy).toHaveBeenCalledWith(snapshot.id, "replace me", undefined);
|
||||||
|
expect(streamAgentSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
replaceAgentRunSpy.mockClear();
|
||||||
|
streamAgentSpy.mockClear();
|
||||||
|
|
||||||
|
const streamed = manager.startAgentRun(snapshot.id, "stream me", {
|
||||||
|
replaceRunning: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(streamed).toEqual({ outOfBand: false, events: streamEvents });
|
||||||
|
expect(streamAgentSpy).toHaveBeenCalledWith(snapshot.id, "stream me", undefined);
|
||||||
|
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("startAgentRun steers a running foreground turn when the provider supports steering", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-run-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const allowFirstRunToEnd = deferred<void>();
|
||||||
|
const steeredPrompts: AgentPromptInput[] = [];
|
||||||
|
let capturedSession: SteerableSession | null = null;
|
||||||
|
|
||||||
|
const steeringCapabilities = {
|
||||||
|
...TEST_CAPABILITIES,
|
||||||
|
supportsSteering: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
class SteerableSession implements AgentSession {
|
||||||
|
readonly provider = "codex" as const;
|
||||||
|
readonly capabilities = steeringCapabilities;
|
||||||
|
readonly id = randomUUID();
|
||||||
|
readonly interrupt = vi.fn(async () => undefined);
|
||||||
|
private subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
|
private turnIdCounter = 0;
|
||||||
|
|
||||||
|
async run(): Promise<AgentRunResult> {
|
||||||
|
return {
|
||||||
|
sessionId: this.id,
|
||||||
|
finalText: "",
|
||||||
|
timeline: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async startTurn(): Promise<{ turnId: string }> {
|
||||||
|
const turnId = `turn-${++this.turnIdCounter}`;
|
||||||
|
void (async () => {
|
||||||
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||||
|
await allowFirstRunToEnd.promise;
|
||||||
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||||
|
})();
|
||||||
|
return { turnId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async steerTurn(prompt: AgentPromptInput): Promise<void> {
|
||||||
|
steeredPrompts.push(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||||
|
this.subscribers.add(callback);
|
||||||
|
return () => {
|
||||||
|
this.subscribers.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushEvent(event: AgentStreamEvent): void {
|
||||||
|
for (const callback of this.subscribers) {
|
||||||
|
callback(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||||
|
async getRuntimeInfo() {
|
||||||
|
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
|
||||||
|
}
|
||||||
|
async getAvailableModes() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getCurrentMode() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
async setMode(): Promise<void> {}
|
||||||
|
getPendingPermissions() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async respondToPermission(): Promise<void> {}
|
||||||
|
describePersistence() {
|
||||||
|
return { provider: this.provider, sessionId: this.id };
|
||||||
|
}
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SteerableClient extends TestAgentClient {
|
||||||
|
readonly capabilities = steeringCapabilities;
|
||||||
|
|
||||||
|
override async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
|
capturedSession = new SteerableSession();
|
||||||
|
return capturedSession;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new SteerableClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000127",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRun = manager.streamAgent(snapshot.id, "first run");
|
||||||
|
const firstRunDrain = (async () => {
|
||||||
|
for await (const _event of firstRun) {
|
||||||
|
// Drain the original foreground run.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await manager.waitForAgentRunStart(snapshot.id);
|
||||||
|
|
||||||
|
const steered = manager.startAgentRun(snapshot.id, "steer this", {
|
||||||
|
replaceRunning: true,
|
||||||
|
});
|
||||||
|
expect(steered.outOfBand).toBe(false);
|
||||||
|
if (!steered.outOfBand) {
|
||||||
|
for await (const _event of steered.events) {
|
||||||
|
// Steering produces no foreground stream events.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(steeredPrompts).toEqual(["steer this"]);
|
||||||
|
expect(capturedSession?.interrupt).not.toHaveBeenCalled();
|
||||||
|
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("running");
|
||||||
|
|
||||||
|
allowFirstRunToEnd.resolve();
|
||||||
|
await firstRunDrain;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("startAgentRun replaces a running foreground turn when steering is unsupported", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unsupported-steer-run-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const allowFirstRunToCancel = deferred<void>();
|
||||||
|
const allowSecondRunToEnd = deferred<void>();
|
||||||
|
let startTurnCount = 0;
|
||||||
|
let interruptCount = 0;
|
||||||
|
|
||||||
|
class UnsupportedSteeringSession implements AgentSession {
|
||||||
|
readonly provider = "codex" as const;
|
||||||
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
|
readonly id = randomUUID();
|
||||||
|
private subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
|
|
||||||
|
async run(): Promise<AgentRunResult> {
|
||||||
|
return {
|
||||||
|
sessionId: this.id,
|
||||||
|
finalText: "",
|
||||||
|
timeline: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async startTurn(): Promise<{ turnId: string }> {
|
||||||
|
startTurnCount += 1;
|
||||||
|
const turnId = `turn-${startTurnCount}`;
|
||||||
|
const turnNumber = startTurnCount;
|
||||||
|
void (async () => {
|
||||||
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||||
|
if (turnNumber === 1) {
|
||||||
|
await allowFirstRunToCancel.promise;
|
||||||
|
this.pushEvent({
|
||||||
|
type: "turn_canceled",
|
||||||
|
provider: this.provider,
|
||||||
|
reason: "interrupted",
|
||||||
|
turnId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await allowSecondRunToEnd.promise;
|
||||||
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||||
|
})();
|
||||||
|
return { turnId };
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||||
|
this.subscribers.add(callback);
|
||||||
|
return () => {
|
||||||
|
this.subscribers.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushEvent(event: AgentStreamEvent): void {
|
||||||
|
for (const callback of this.subscribers) {
|
||||||
|
callback(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||||
|
async getRuntimeInfo() {
|
||||||
|
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
|
||||||
|
}
|
||||||
|
async getAvailableModes() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getCurrentMode() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
async setMode(): Promise<void> {}
|
||||||
|
getPendingPermissions() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async respondToPermission(): Promise<void> {}
|
||||||
|
describePersistence() {
|
||||||
|
return { provider: this.provider, sessionId: this.id };
|
||||||
|
}
|
||||||
|
async interrupt(): Promise<void> {
|
||||||
|
interruptCount += 1;
|
||||||
|
allowFirstRunToCancel.resolve();
|
||||||
|
}
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnsupportedSteeringClient extends TestAgentClient {
|
||||||
|
override async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
|
return new UnsupportedSteeringSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new UnsupportedSteeringClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000128",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRun = manager.streamAgent(snapshot.id, "first run");
|
||||||
|
const firstRunDrain = (async () => {
|
||||||
|
for await (const _event of firstRun) {
|
||||||
|
// Drain the original foreground run.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await manager.waitForAgentRunStart(snapshot.id);
|
||||||
|
|
||||||
|
const replacement = manager.startAgentRun(snapshot.id, "replace this", {
|
||||||
|
replaceRunning: true,
|
||||||
|
});
|
||||||
|
expect(replacement.outOfBand).toBe(false);
|
||||||
|
const replacementDrain =
|
||||||
|
replacement.outOfBand === false
|
||||||
|
? (async () => {
|
||||||
|
for await (const _event of replacement.events) {
|
||||||
|
// Drain the replacement foreground run.
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
|
await manager.waitForAgentRunStart(snapshot.id);
|
||||||
|
expect(interruptCount).toBe(1);
|
||||||
|
expect(startTurnCount).toBe(2);
|
||||||
|
|
||||||
|
allowSecondRunToEnd.resolve();
|
||||||
|
await firstRunDrain;
|
||||||
|
await replacementDrain;
|
||||||
|
});
|
||||||
|
|
||||||
test("replaceAgentRun stays running when a stale old terminal arrives before the replacement turn is current", async () => {
|
test("replaceAgentRun stays running when a stale old terminal arrives before the replacement turn is current", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-stale-terminal-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-stale-terminal-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import {
|
|||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentSlashCommand,
|
type AgentSlashCommand,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
type AgentPlanResponse,
|
|
||||||
type AgentPlanResult,
|
|
||||||
type AgentPermissionRequest,
|
type AgentPermissionRequest,
|
||||||
type AgentPermissionResponse,
|
type AgentPermissionResponse,
|
||||||
type AgentPermissionResult,
|
type AgentPermissionResult,
|
||||||
@@ -207,6 +205,15 @@ export interface WaitForAgentStartOptions {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StartAgentRunOptions {
|
||||||
|
replaceRunning?: boolean;
|
||||||
|
runOptions?: AgentRunOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StartAgentRunResult =
|
||||||
|
| { outOfBand: true }
|
||||||
|
| { outOfBand: false; events: AsyncGenerator<AgentStreamEvent> };
|
||||||
|
|
||||||
type AttentionState =
|
type AttentionState =
|
||||||
| { requiresAttention: false }
|
| { requiresAttention: false }
|
||||||
| {
|
| {
|
||||||
@@ -1484,6 +1491,55 @@ export class AgentManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startAgentRun(
|
||||||
|
agentId: string,
|
||||||
|
prompt: AgentPromptInput,
|
||||||
|
options?: StartAgentRunOptions,
|
||||||
|
): StartAgentRunResult {
|
||||||
|
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
|
||||||
|
// in-flight turn. Keeping this policy here makes every send surface share
|
||||||
|
// the same replace-vs-stream decision.
|
||||||
|
if (this.tryRunOutOfBand(agentId, prompt)) {
|
||||||
|
return { outOfBand: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldReplace = Boolean(options?.replaceRunning && this.hasInFlightRun(agentId));
|
||||||
|
const runOptions = options?.runOptions;
|
||||||
|
if (shouldReplace && this.canSteerActiveForegroundRun(agentId)) {
|
||||||
|
const events = this.steerAgentRun(agentId, prompt, runOptions);
|
||||||
|
return { outOfBand: false, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = shouldReplace
|
||||||
|
? this.replaceAgentRun(agentId, prompt, runOptions)
|
||||||
|
: this.streamAgent(agentId, prompt, runOptions);
|
||||||
|
return { outOfBand: false, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
private canSteerActiveForegroundRun(agentId: string): boolean {
|
||||||
|
const agent = this.agents.get(agentId);
|
||||||
|
return Boolean(
|
||||||
|
agent?.activeForegroundTurnId &&
|
||||||
|
agent.capabilities.supportsSteering === true &&
|
||||||
|
agent.session.steerTurn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *steerAgentRun(
|
||||||
|
agentId: string,
|
||||||
|
prompt: AgentPromptInput,
|
||||||
|
options?: AgentRunOptions,
|
||||||
|
): AsyncGenerator<AgentStreamEvent> {
|
||||||
|
const agent = this.requireSessionAgent(agentId);
|
||||||
|
if (!agent.session.steerTurn) {
|
||||||
|
throw new Error(`Agent ${agentId} does not support steering`);
|
||||||
|
}
|
||||||
|
await agent.session.steerTurn(prompt, options);
|
||||||
|
this.touchUpdatedAt(agent);
|
||||||
|
this.emitState(agent);
|
||||||
|
yield* [];
|
||||||
|
}
|
||||||
|
|
||||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
this.touchUpdatedAt(agent);
|
this.touchUpdatedAt(agent);
|
||||||
@@ -1838,31 +1894,6 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async respondToPlan(
|
|
||||||
agentId: string,
|
|
||||||
planId: string,
|
|
||||||
response: AgentPlanResponse,
|
|
||||||
): Promise<AgentPlanResult | void> {
|
|
||||||
const agent = this.requireAgent(agentId);
|
|
||||||
if (!agent.session.respondToPlan) {
|
|
||||||
throw new Error(`Agent provider '${agent.provider}' does not support plan responses`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await agent.session.respondToPlan(planId, response);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.refreshSessionState(agent);
|
|
||||||
} catch {
|
|
||||||
// Ignore refresh errors - state sync after plan response is best effort.
|
|
||||||
}
|
|
||||||
|
|
||||||
this.touchUpdatedAt(agent);
|
|
||||||
await this.persistSnapshot(agent);
|
|
||||||
this.emitState(agent);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||||
const agent = this.requireSessionAgent(agentId);
|
const agent = this.requireSessionAgent(agentId);
|
||||||
const pendingRun = this.foregroundRuns.getPendingRun(agentId);
|
const pendingRun = this.foregroundRuns.getPendingRun(agentId);
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
} from "./agent-prompt.js";
|
} from "./agent-prompt.js";
|
||||||
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||||
|
|
||||||
|
const CHILD_AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const CALLER_AGENT_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
|
||||||
test("isSystemInjectedEnvelope matches the envelope formatSystemNotificationPrompt produces", () => {
|
test("isSystemInjectedEnvelope matches the envelope formatSystemNotificationPrompt produces", () => {
|
||||||
expect(isSystemInjectedEnvelope(formatSystemNotificationPrompt("child finished"))).toBe(true);
|
expect(isSystemInjectedEnvelope(formatSystemNotificationPrompt("child finished"))).toBe(true);
|
||||||
expect(isSystemInjectedEnvelope("hello world")).toBe(false);
|
expect(isSystemInjectedEnvelope("hello world")).toBe(false);
|
||||||
@@ -19,27 +22,24 @@ it("does not notify archived callers", async () => {
|
|||||||
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||||
|
|
||||||
const childAgent: ManagedAgent = Object.create(null);
|
const childAgent: ManagedAgent = Object.create(null);
|
||||||
Reflect.set(childAgent, "id", "child-agent");
|
Reflect.set(childAgent, "id", CHILD_AGENT_ID);
|
||||||
Reflect.set(childAgent, "lifecycle", "idle");
|
Reflect.set(childAgent, "lifecycle", "idle");
|
||||||
Reflect.set(childAgent, "config", { title: "Child Agent" });
|
Reflect.set(childAgent, "config", { title: "Child Agent" });
|
||||||
|
|
||||||
const callerAgent: ManagedAgent = Object.create(null);
|
const callerAgent: ManagedAgent = Object.create(null);
|
||||||
Reflect.set(callerAgent, "id", "caller-agent");
|
Reflect.set(callerAgent, "id", CALLER_AGENT_ID);
|
||||||
Reflect.set(callerAgent, "lifecycle", "idle");
|
Reflect.set(callerAgent, "lifecycle", "idle");
|
||||||
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
|
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
|
||||||
|
|
||||||
const streamAgentSpy = vi.fn(() => (async function* noop() {})());
|
|
||||||
const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})());
|
|
||||||
|
|
||||||
const agentManager: AgentManager = Object.create(AgentManager.prototype);
|
const agentManager: AgentManager = Object.create(AgentManager.prototype);
|
||||||
Reflect.set(
|
Reflect.set(
|
||||||
agentManager,
|
agentManager,
|
||||||
"getAgent",
|
"getAgent",
|
||||||
vi.fn((agentId: string) => {
|
vi.fn((agentId: string) => {
|
||||||
if (agentId === "child-agent") {
|
if (agentId === CHILD_AGENT_ID) {
|
||||||
return childAgent;
|
return childAgent;
|
||||||
}
|
}
|
||||||
if (agentId === "caller-agent") {
|
if (agentId === CALLER_AGENT_ID) {
|
||||||
return callerAgent;
|
return callerAgent;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -55,12 +55,14 @@ it("does not notify archived callers", async () => {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
Reflect.set(agentManager, "hasInFlightRun", vi.fn().mockReturnValue(false));
|
const startAgentRunSpy = vi.fn(() => ({
|
||||||
Reflect.set(agentManager, "streamAgent", streamAgentSpy);
|
outOfBand: false,
|
||||||
Reflect.set(agentManager, "replaceAgentRun", replaceAgentRunSpy);
|
events: (async function* noop() {})(),
|
||||||
|
}));
|
||||||
|
Reflect.set(agentManager, "startAgentRun", startAgentRunSpy);
|
||||||
|
|
||||||
const agentStorageGetSpy = vi.fn(async (agentId: string) =>
|
const agentStorageGetSpy = vi.fn(async (agentId: string) =>
|
||||||
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
|
agentId === CALLER_AGENT_ID ? { archivedAt: "2024-01-01" } : null,
|
||||||
);
|
);
|
||||||
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
|
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
|
||||||
Reflect.set(agentStorage, "get", agentStorageGetSpy);
|
Reflect.set(agentStorage, "get", agentStorageGetSpy);
|
||||||
@@ -68,8 +70,8 @@ it("does not notify archived callers", async () => {
|
|||||||
setupFinishNotification({
|
setupFinishNotification({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
childAgentId: "child-agent",
|
childAgentId: CHILD_AGENT_ID,
|
||||||
callerAgentId: "caller-agent",
|
callerAgentId: CALLER_AGENT_ID,
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,9 +90,92 @@ it("does not notify archived callers", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent");
|
expect(agentStorageGetSpy).toHaveBeenCalledWith(CALLER_AGENT_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(streamAgentSpy).not.toHaveBeenCalled();
|
expect(startAgentRunSpy).not.toHaveBeenCalled();
|
||||||
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
|
});
|
||||||
|
|
||||||
|
it("uses AgentManager startAgentRun for finish notifications", async () => {
|
||||||
|
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||||
|
|
||||||
|
const childAgent: ManagedAgent = Object.create(null);
|
||||||
|
Reflect.set(childAgent, "id", CHILD_AGENT_ID);
|
||||||
|
Reflect.set(childAgent, "lifecycle", "idle");
|
||||||
|
Reflect.set(childAgent, "config", { title: "Child Agent" });
|
||||||
|
|
||||||
|
const callerAgent: ManagedAgent = Object.create(null);
|
||||||
|
Reflect.set(callerAgent, "id", CALLER_AGENT_ID);
|
||||||
|
Reflect.set(callerAgent, "lifecycle", "idle");
|
||||||
|
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
|
||||||
|
|
||||||
|
const startAgentRunSpy = vi.fn(() => ({
|
||||||
|
outOfBand: false,
|
||||||
|
events: (async function* noop() {})(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const agentManager: AgentManager = Object.create(AgentManager.prototype);
|
||||||
|
Reflect.set(
|
||||||
|
agentManager,
|
||||||
|
"getAgent",
|
||||||
|
vi.fn((agentId: string) => {
|
||||||
|
if (agentId === CHILD_AGENT_ID) {
|
||||||
|
return childAgent;
|
||||||
|
}
|
||||||
|
if (agentId === CALLER_AGENT_ID) {
|
||||||
|
return callerAgent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Reflect.set(
|
||||||
|
agentManager,
|
||||||
|
"subscribe",
|
||||||
|
vi.fn((callback: (event: AgentManagerEvent) => void) => {
|
||||||
|
subscriber = callback;
|
||||||
|
return () => {
|
||||||
|
subscriber = null;
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Reflect.set(agentManager, "startAgentRun", startAgentRunSpy);
|
||||||
|
|
||||||
|
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
|
||||||
|
Reflect.set(
|
||||||
|
agentStorage,
|
||||||
|
"get",
|
||||||
|
vi.fn(async (agentId: string) =>
|
||||||
|
agentId === CHILD_AGENT_ID ? { title: "Child Agent" } : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
setupFinishNotification({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
childAgentId: CHILD_AGENT_ID,
|
||||||
|
callerAgentId: CALLER_AGENT_ID,
|
||||||
|
logger: createTestLogger(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(subscriber).not.toBeNull();
|
||||||
|
|
||||||
|
childAgent.lifecycle = "running";
|
||||||
|
subscriber?.({
|
||||||
|
type: "agent_state",
|
||||||
|
agent: childAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
childAgent.lifecycle = "idle";
|
||||||
|
subscriber?.({
|
||||||
|
type: "agent_state",
|
||||||
|
agent: childAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(startAgentRunSpy).toHaveBeenCalledWith(
|
||||||
|
CALLER_AGENT_ID,
|
||||||
|
`<paseo-system>\nAgent ${CHILD_AGENT_ID} (Child Agent) finished.\n</paseo-system>`,
|
||||||
|
{ replaceRunning: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
|
import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
|
||||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
import type { AgentManager, ManagedAgent, StartAgentRunOptions } from "./agent-manager.js";
|
||||||
import type { AgentStorage } from "./agent-storage.js";
|
import type { AgentStorage } from "./agent-storage.js";
|
||||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||||
|
|
||||||
export type AgentRunController = Pick<
|
export type AgentRunController = Pick<AgentManager, "getAgent" | "startAgentRun">;
|
||||||
AgentManager,
|
|
||||||
"getAgent" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" | "streamAgent"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface StartAgentRunOptions {
|
|
||||||
replaceRunning?: boolean;
|
|
||||||
runOptions?: AgentRunOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startAgentRun(
|
export function startAgentRun(
|
||||||
agentManager: AgentRunController,
|
agentManager: AgentRunController,
|
||||||
@@ -35,23 +27,17 @@ export function startAgentRun(
|
|||||||
},
|
},
|
||||||
"agent.session.start_stream.request",
|
"agent.session.start_stream.request",
|
||||||
);
|
);
|
||||||
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
|
|
||||||
// in-flight turn — replaceAgentRun would interrupt the running turn. The
|
const result = agentManager.startAgentRun(agentId, prompt, options);
|
||||||
// intercept lives at this layer so it covers every prompt entrypoint.
|
if (result.outOfBand) {
|
||||||
if (agentManager.tryRunOutOfBand(agentId, prompt)) {
|
|
||||||
return { outOfBand: true };
|
return { outOfBand: true };
|
||||||
}
|
}
|
||||||
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
const iterator = result.events;
|
||||||
const runOptions = options?.runOptions;
|
|
||||||
const iterator = shouldReplace
|
|
||||||
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
|
|
||||||
: agentManager.streamAgent(agentId, prompt, runOptions);
|
|
||||||
logger.trace(
|
logger.trace(
|
||||||
{
|
{
|
||||||
agentId,
|
agentId,
|
||||||
provider: snapshot?.provider,
|
provider: snapshot?.provider,
|
||||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
||||||
shouldReplace,
|
|
||||||
},
|
},
|
||||||
"agent.session.start_stream.iterator_returned",
|
"agent.session.start_stream.iterator_returned",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export interface AgentCapabilityFlags {
|
|||||||
supportsRewindConversation?: boolean;
|
supportsRewindConversation?: boolean;
|
||||||
supportsRewindFiles?: boolean;
|
supportsRewindFiles?: boolean;
|
||||||
supportsRewindBoth?: boolean;
|
supportsRewindBoth?: boolean;
|
||||||
|
supportsSteering?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentPersistenceHandle {
|
export interface AgentPersistenceHandle {
|
||||||
@@ -342,27 +343,12 @@ export interface CompactionTimelineItem {
|
|||||||
preTokens?: number;
|
preTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentPlanAction {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
variant?: "primary" | "secondary" | "danger";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlanTimelineItem {
|
|
||||||
[key: string]: unknown;
|
|
||||||
type: "plan";
|
|
||||||
planId: string;
|
|
||||||
text: string;
|
|
||||||
actions?: AgentPlanAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentTimelineItem =
|
export type AgentTimelineItem =
|
||||||
| { type: "user_message"; text: string; messageId?: string }
|
| { type: "user_message"; text: string; messageId?: string }
|
||||||
| { type: "assistant_message"; text: string; messageId?: string }
|
| { type: "assistant_message"; text: string; messageId?: string }
|
||||||
| { type: "reasoning"; text: string }
|
| { type: "reasoning"; text: string }
|
||||||
| ToolCallTimelineItem
|
| ToolCallTimelineItem
|
||||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
||||||
| PlanTimelineItem
|
|
||||||
| { type: "error"; message: string }
|
| { type: "error"; message: string }
|
||||||
| CompactionTimelineItem;
|
| CompactionTimelineItem;
|
||||||
|
|
||||||
@@ -566,15 +552,6 @@ export interface AgentPermissionResult {
|
|||||||
followUpPrompt?: AgentPromptInput;
|
followUpPrompt?: AgentPromptInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentPlanResponse {
|
|
||||||
actionId: string;
|
|
||||||
feedback?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentPlanResult {
|
|
||||||
followUpPrompt?: AgentPromptInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentSession {
|
export interface AgentSession {
|
||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly id: string | null;
|
readonly id: string | null;
|
||||||
@@ -582,6 +559,7 @@ export interface AgentSession {
|
|||||||
readonly features?: AgentFeature[];
|
readonly features?: AgentFeature[];
|
||||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||||
|
steerTurn?(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<void>;
|
||||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||||
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||||
@@ -593,7 +571,6 @@ export interface AgentSession {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
response: AgentPermissionResponse,
|
response: AgentPermissionResponse,
|
||||||
): Promise<AgentPermissionResult | void>;
|
): Promise<AgentPermissionResult | void>;
|
||||||
respondToPlan?(planId: string, response: AgentPlanResponse): Promise<AgentPlanResult | void>;
|
|
||||||
describePersistence(): AgentPersistenceHandle | null;
|
describePersistence(): AgentPersistenceHandle | null;
|
||||||
interrupt(): Promise<void>;
|
interrupt(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { AgentStreamEvent } from "../messages.js";
|
import type { AgentStreamEvent } from "../messages.js";
|
||||||
|
import type { StartAgentRunOptions, StartAgentRunResult } from "./agent-manager.js";
|
||||||
import { respondToAgentPermission } from "./permission-response.js";
|
import { respondToAgentPermission } from "./permission-response.js";
|
||||||
|
|
||||||
class FakePermissionAgentManager {
|
class FakePermissionAgentManager {
|
||||||
@@ -61,6 +62,21 @@ class FakePermissionAgentManager {
|
|||||||
this.replacementRuns.push({ agentId, prompt, options });
|
this.replacementRuns.push({ agentId, prompt, options });
|
||||||
return emptyAgentStream();
|
return emptyAgentStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startAgentRun(
|
||||||
|
agentId: string,
|
||||||
|
prompt: AgentPromptInput,
|
||||||
|
options?: StartAgentRunOptions,
|
||||||
|
): StartAgentRunResult {
|
||||||
|
if (this.tryRunOutOfBand()) {
|
||||||
|
return { outOfBand: true };
|
||||||
|
}
|
||||||
|
const shouldReplace = Boolean(options?.replaceRunning && this.hasInFlightRun());
|
||||||
|
const events = shouldReplace
|
||||||
|
? this.replaceAgentRun(agentId, prompt, options?.runOptions)
|
||||||
|
: this.streamAgent(agentId, prompt, options?.runOptions);
|
||||||
|
return { outOfBand: false, events };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function* emptyAgentStream(): AsyncGenerator<AgentStreamEvent> {}
|
async function* emptyAgentStream(): AsyncGenerator<AgentStreamEvent> {}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { isPlanFilePath, planItemFromToolCall } from "./plan-files.js";
|
|
||||||
|
|
||||||
describe("plan file detection", () => {
|
|
||||||
test("accepts only narrow Paseo and OpenCode plan markdown paths", () => {
|
|
||||||
expect(isPlanFilePath(".paseo/plans/feature.md")).toBe(true);
|
|
||||||
expect(isPlanFilePath("/Users/me/project/.paseo/plans/feature.markdown")).toBe(true);
|
|
||||||
expect(isPlanFilePath(".opencode/plans/refactor.md")).toBe(true);
|
|
||||||
expect(isPlanFilePath("/Users/me/.opencode/plans/refactor.markdown")).toBe(true);
|
|
||||||
|
|
||||||
expect(isPlanFilePath("PLAN.md")).toBe(false);
|
|
||||||
expect(isPlanFilePath("docs/plan.md")).toBe(false);
|
|
||||||
expect(isPlanFilePath(".paseo/notes/feature.md")).toBe(false);
|
|
||||||
expect(isPlanFilePath(".paseo/plans/feature.txt")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("turns successful plan writes into non-actionable plan items", async () => {
|
|
||||||
const item = await planItemFromToolCall({
|
|
||||||
cwd: "/workspace",
|
|
||||||
homeDir: "/Users/me",
|
|
||||||
item: {
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "write-plan",
|
|
||||||
name: "write",
|
|
||||||
status: "completed",
|
|
||||||
error: null,
|
|
||||||
detail: {
|
|
||||||
type: "write",
|
|
||||||
filePath: ".paseo/plans/feature.md",
|
|
||||||
content: "# Plan\n\n- Implement it",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(item).toEqual({
|
|
||||||
type: "plan",
|
|
||||||
planId: "plan-file:.paseo/plans/feature.md",
|
|
||||||
text: "# Plan\n\n- Implement it",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import type { AgentTimelineItem, ToolCallTimelineItem } from "./agent-sdk-types.js";
|
|
||||||
|
|
||||||
const PLAN_FILE_EXTENSIONS = new Set([".md", ".markdown"]);
|
|
||||||
const PLAN_DIRECTORIES = new Set(["/.paseo/plans/", "/.opencode/plans/"]);
|
|
||||||
|
|
||||||
export function isPlanFilePath(filePath: string): boolean {
|
|
||||||
const normalized = normalizePlanPath(filePath);
|
|
||||||
const ext = path.posix.extname(normalized).toLowerCase();
|
|
||||||
if (!PLAN_FILE_EXTENSIONS.has(ext)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const searchable = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
||||||
return Array.from(PLAN_DIRECTORIES).some((dir) => searchable.includes(dir));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function planItemFromToolCall(params: {
|
|
||||||
item: ToolCallTimelineItem;
|
|
||||||
cwd: string;
|
|
||||||
homeDir: string;
|
|
||||||
}): Promise<AgentTimelineItem | null> {
|
|
||||||
const { item, cwd, homeDir } = params;
|
|
||||||
if (item.status !== "completed") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const detail = item.detail;
|
|
||||||
if (detail.type !== "write" && detail.type !== "edit") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!isPlanFilePath(detail.filePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const explicitContent = detail.type === "write" ? detail.content : undefined;
|
|
||||||
const text = explicitContent ?? (await readPlanFile(detail.filePath, cwd, homeDir));
|
|
||||||
if (!text?.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "plan",
|
|
||||||
planId: `plan-file:${normalizePlanPath(detail.filePath)}`,
|
|
||||||
text: text.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePlanPath(filePath: string): string {
|
|
||||||
return path.posix.normalize(filePath.replace(/\\/g, "/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readPlanFile(
|
|
||||||
filePath: string,
|
|
||||||
cwd: string,
|
|
||||||
homeDir: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const candidates = path.isAbsolute(filePath)
|
|
||||||
? [filePath]
|
|
||||||
: [path.resolve(cwd, filePath), path.resolve(homeDir, filePath)];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
try {
|
|
||||||
return await fs.readFile(candidate, "utf8");
|
|
||||||
} catch {
|
|
||||||
// Try the next candidate.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -351,6 +351,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
|||||||
},
|
},
|
||||||
run: (prompt, options) => inner.run(prompt, options),
|
run: (prompt, options) => inner.run(prompt, options),
|
||||||
startTurn: (prompt, options) => inner.startTurn(prompt, options),
|
startTurn: (prompt, options) => inner.startTurn(prompt, options),
|
||||||
|
steerTurn: inner.steerTurn?.bind(inner),
|
||||||
subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))),
|
subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))),
|
||||||
async *streamHistory() {
|
async *streamHistory() {
|
||||||
for await (const event of inner.streamHistory()) {
|
for await (const event of inner.streamHistory()) {
|
||||||
@@ -363,7 +364,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
|||||||
setMode: (modeId) => inner.setMode(modeId),
|
setMode: (modeId) => inner.setMode(modeId),
|
||||||
getPendingPermissions: () => inner.getPendingPermissions(),
|
getPendingPermissions: () => inner.getPendingPermissions(),
|
||||||
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
|
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
|
||||||
respondToPlan: inner.respondToPlan?.bind(inner),
|
|
||||||
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
|
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
|
||||||
interrupt: () => inner.interrupt(),
|
interrupt: () => inner.interrupt(),
|
||||||
close: () => inner.close(),
|
close: () => inner.close(),
|
||||||
|
|||||||
@@ -997,7 +997,7 @@ test("preserves bypass capability across query restarts triggered by thinking ch
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plan item exposes a resume-bypass action and can return to bypassPermissions", async () => {
|
test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => {
|
||||||
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
||||||
sdkQueryFactory.mockImplementation(() => queryMock);
|
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||||
|
|
||||||
@@ -1023,36 +1023,44 @@ test("plan item exposes a resume-bypass action and can return to bypassPermissio
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const planEvent = events.find(
|
const requestEvent = events.find(
|
||||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
event.type === "timeline" && event.item.type === "plan",
|
event.type === "permission_requested" && event.request.kind === "plan",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(planEvent).toBeDefined();
|
expect(requestEvent).toBeDefined();
|
||||||
expect(planEvent?.item.type === "plan" ? planEvent.item.actions : undefined).toEqual([
|
expect(requestEvent?.request.actions).toEqual([
|
||||||
{
|
{
|
||||||
id: "reject",
|
id: "reject",
|
||||||
label: "Reject",
|
label: "Reject",
|
||||||
|
behavior: "deny",
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
|
intent: "dismiss",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "implement",
|
id: "implement",
|
||||||
label: "Implement",
|
label: "Implement",
|
||||||
|
behavior: "allow",
|
||||||
variant: "primary",
|
variant: "primary",
|
||||||
|
intent: "implement",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "implement_resume",
|
id: "implement_resume",
|
||||||
label: "Implement with Bypass",
|
label: "Implement with Bypass",
|
||||||
|
behavior: "allow",
|
||||||
variant: "secondary",
|
variant: "secondary",
|
||||||
|
intent: "implement_resume",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!planEvent || planEvent.item.type !== "plan") {
|
if (!requestEvent) {
|
||||||
throw new Error("Expected plan item");
|
throw new Error("Expected plan permission request");
|
||||||
}
|
}
|
||||||
expect(session.getPendingPermissions()).toEqual([]);
|
|
||||||
|
|
||||||
await session.respondToPlan?.(planEvent.item.planId, { actionId: "implement_resume" });
|
await session.respondToPermission(requestEvent.request.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement_resume",
|
||||||
|
});
|
||||||
|
|
||||||
await expect(pendingResolution).resolves.toMatchObject({
|
await expect(pendingResolution).resolves.toMatchObject({
|
||||||
behavior: "allow",
|
behavior: "allow",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||||
import * as executableUtils from "../../../../utils/executable.js";
|
import * as executableUtils from "../../../../utils/executable.js";
|
||||||
@@ -749,7 +749,10 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
return session as unknown as TestClaudeSession;
|
return session as unknown as TestClaudeSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createQueryFactoryForTurns(turns: Array<Array<Record<string, unknown>>>) {
|
function createQueryFactoryForTurns(
|
||||||
|
turns: Array<Array<Record<string, unknown>>>,
|
||||||
|
onPrompt?: (prompt: unknown) => void,
|
||||||
|
) {
|
||||||
return vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
return vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
const queuedMessages: Array<Record<string, unknown>> = [];
|
const queuedMessages: Array<Record<string, unknown>> = [];
|
||||||
const waiters: Array<() => void> = [];
|
const waiters: Array<() => void> = [];
|
||||||
@@ -768,6 +771,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
for await (const _prompt of prompt) {
|
for await (const _prompt of prompt) {
|
||||||
|
onPrompt?.(_prompt);
|
||||||
const turnMessages = turns[turnIndex] ?? [];
|
const turnMessages = turns[turnIndex] ?? [];
|
||||||
turnIndex += 1;
|
turnIndex += 1;
|
||||||
for (const message of turnMessages) {
|
for (const message of turnMessages) {
|
||||||
@@ -1003,6 +1007,42 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("steerTurn pushes a priority next user message into the existing input stream", async () => {
|
||||||
|
const capturedPrompts: SDKUserMessage[] = [];
|
||||||
|
const queryFactory = createQueryFactoryForTurns([], (prompt) => {
|
||||||
|
capturedPrompts.push(prompt as SDKUserMessage);
|
||||||
|
});
|
||||||
|
const client = new ClaudeAgentClient({ logger, queryFactory });
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await session.startTurn("first prompt");
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(capturedPrompts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(session.steerTurn?.("steered prompt")).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(capturedPrompts).toHaveLength(2);
|
||||||
|
});
|
||||||
|
expect(capturedPrompts[1]).toMatchObject({
|
||||||
|
type: "user",
|
||||||
|
priority: "next",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "steered prompt" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
|
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
|
||||||
const session = await createSessionForTest();
|
const session = await createSessionForTest();
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,6 @@ import {
|
|||||||
type AgentMetadata,
|
type AgentMetadata,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
type AgentModelDefinition,
|
type AgentModelDefinition,
|
||||||
type AgentPlanAction,
|
|
||||||
type AgentPlanResponse,
|
|
||||||
type AgentPermissionRequest,
|
type AgentPermissionRequest,
|
||||||
type AgentPermissionRequestKind,
|
type AgentPermissionRequestKind,
|
||||||
type AgentPermissionResponse,
|
type AgentPermissionResponse,
|
||||||
@@ -218,6 +216,7 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsRewindConversation: true,
|
supportsRewindConversation: true,
|
||||||
supportsRewindFiles: true,
|
supportsRewindFiles: true,
|
||||||
supportsRewindBoth: true,
|
supportsRewindBoth: true,
|
||||||
|
supportsSteering: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MODES: AgentMode[] = [
|
const DEFAULT_MODES: AgentMode[] = [
|
||||||
@@ -899,14 +898,6 @@ function buildClaudePlanPermissionActions(
|
|||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClaudePlanActions(resumeMode: PermissionMode | null): AgentPlanAction[] {
|
|
||||||
return buildClaudePlanPermissionActions(resumeMode).map(({ id, label, variant }) => ({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
variant,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TimelineFragment {
|
interface TimelineFragment {
|
||||||
kind: "assistant" | "reasoning";
|
kind: "assistant" | "reasoning";
|
||||||
text: string;
|
text: string;
|
||||||
@@ -1594,7 +1585,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private toolUseIndexToId = new Map<number, string>();
|
private toolUseIndexToId = new Map<number, string>();
|
||||||
private toolUseInputBuffers = new Map<string, string>();
|
private toolUseInputBuffers = new Map<string, string>();
|
||||||
private pendingPermissions = new Map<string, PendingPermission>();
|
private pendingPermissions = new Map<string, PendingPermission>();
|
||||||
private pendingPlans = new Map<string, string>();
|
|
||||||
private activeForegroundTurnId: string | null = null;
|
private activeForegroundTurnId: string | null = null;
|
||||||
private autonomousTurn: AutonomousTurnState | null = null;
|
private autonomousTurn: AutonomousTurnState | null = null;
|
||||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
@@ -1800,6 +1790,20 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return { turnId };
|
return { turnId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async steerTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<void> {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new Error("Claude session is closed");
|
||||||
|
}
|
||||||
|
const sdkMessage = this.toSdkUserMessage(prompt);
|
||||||
|
sdkMessage.priority = "next";
|
||||||
|
|
||||||
|
await this.ensureQuery();
|
||||||
|
if (!this.input) {
|
||||||
|
throw new Error("Claude session input stream not initialized");
|
||||||
|
}
|
||||||
|
this.input.push(sdkMessage);
|
||||||
|
}
|
||||||
|
|
||||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||||
this.subscribers.add(callback);
|
this.subscribers.add(callback);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1930,140 +1934,82 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getPendingPermissions(): AgentPermissionRequest[] {
|
getPendingPermissions(): AgentPermissionRequest[] {
|
||||||
const hiddenPlanRequestIds = new Set(this.pendingPlans.values());
|
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
|
||||||
return Array.from(this.pendingPermissions.values())
|
|
||||||
.filter((entry) => !hiddenPlanRequestIds.has(entry.request.id))
|
|
||||||
.map((entry) => entry.request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearPendingPlanForPermission(requestId: string): void {
|
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
|
||||||
for (const [planId, pendingRequestId] of this.pendingPlans) {
|
|
||||||
if (pendingRequestId === requestId) {
|
|
||||||
this.pendingPlans.delete(planId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async respondToPermission(
|
|
||||||
requestId: string,
|
|
||||||
response: AgentPermissionResponse,
|
|
||||||
emitResolution = true,
|
|
||||||
): Promise<void> {
|
|
||||||
const pending = this.pendingPermissions.get(requestId);
|
const pending = this.pendingPermissions.get(requestId);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
throw new Error(`No pending permission request with id '${requestId}'`);
|
throw new Error(`No pending permission request with id '${requestId}'`);
|
||||||
}
|
}
|
||||||
this.pendingPermissions.delete(requestId);
|
this.pendingPermissions.delete(requestId);
|
||||||
this.clearPendingPlanForPermission(requestId);
|
|
||||||
pending.cleanup?.();
|
pending.cleanup?.();
|
||||||
|
|
||||||
if (response.behavior === "allow") {
|
if (response.behavior === "allow") {
|
||||||
await this.resolveAllowedPermission(pending, response);
|
if (pending.request.kind === "plan") {
|
||||||
|
const selectedActionId = response.selectedActionId;
|
||||||
|
const shouldResumePriorMode =
|
||||||
|
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
|
||||||
|
const targetMode: PermissionMode = shouldResumePriorMode
|
||||||
|
? "bypassPermissions"
|
||||||
|
: "acceptEdits";
|
||||||
|
await this.setMode(targetMode);
|
||||||
|
this.pushToolCall(
|
||||||
|
mapClaudeCompletedToolCall({
|
||||||
|
name: "plan_approval",
|
||||||
|
callId: pending.request.id,
|
||||||
|
input: pending.request.input ?? null,
|
||||||
|
output: {
|
||||||
|
approved: true,
|
||||||
|
actionId: selectedActionId ?? "implement",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updatedInput =
|
||||||
|
pending.request.kind === "question"
|
||||||
|
? normalizeClaudeAskUserQuestionUpdatedInput(
|
||||||
|
response.updatedInput,
|
||||||
|
pending.request.input ?? undefined,
|
||||||
|
)
|
||||||
|
: (response.updatedInput ?? pending.request.input ?? {});
|
||||||
|
const result: PermissionResult = {
|
||||||
|
behavior: "allow",
|
||||||
|
updatedInput,
|
||||||
|
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
||||||
|
};
|
||||||
|
pending.resolve(result);
|
||||||
} else {
|
} else {
|
||||||
this.resolveDeniedPermission(pending, response);
|
if (pending.request.kind === "tool") {
|
||||||
|
this.pushToolCall(
|
||||||
|
mapClaudeFailedToolCall({
|
||||||
|
name: pending.request.name,
|
||||||
|
callId:
|
||||||
|
(typeof pending.request.metadata?.toolUseId === "string"
|
||||||
|
? pending.request.metadata.toolUseId
|
||||||
|
: null) ?? pending.request.id,
|
||||||
|
input: pending.request.input ?? null,
|
||||||
|
output: null,
|
||||||
|
error: { message: response.message ?? "Permission denied" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result: PermissionResult = {
|
||||||
|
behavior: "deny",
|
||||||
|
message: response.message ?? "Permission request denied",
|
||||||
|
interrupt: response.interrupt,
|
||||||
|
};
|
||||||
|
pending.resolve(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emitResolution) {
|
this.pushEvent({
|
||||||
this.pushEvent({
|
type: "permission_resolved",
|
||||||
type: "permission_resolved",
|
provider: "claude",
|
||||||
provider: "claude",
|
requestId,
|
||||||
requestId,
|
resolution: response,
|
||||||
resolution: response,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveAllowedPermission(
|
|
||||||
pending: PendingPermission,
|
|
||||||
response: Extract<AgentPermissionResponse, { behavior: "allow" }>,
|
|
||||||
): Promise<void> {
|
|
||||||
if (pending.request.kind === "plan") {
|
|
||||||
const selectedActionId = response.selectedActionId;
|
|
||||||
const shouldResumePriorMode =
|
|
||||||
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
|
|
||||||
const targetMode: PermissionMode = shouldResumePriorMode
|
|
||||||
? "bypassPermissions"
|
|
||||||
: "acceptEdits";
|
|
||||||
await this.setMode(targetMode);
|
|
||||||
this.pushToolCall(
|
|
||||||
mapClaudeCompletedToolCall({
|
|
||||||
name: "plan_approval",
|
|
||||||
callId: pending.request.id,
|
|
||||||
input: pending.request.input ?? null,
|
|
||||||
output: {
|
|
||||||
approved: true,
|
|
||||||
actionId: selectedActionId ?? "implement",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const updatedInput =
|
|
||||||
pending.request.kind === "question"
|
|
||||||
? normalizeClaudeAskUserQuestionUpdatedInput(
|
|
||||||
response.updatedInput,
|
|
||||||
pending.request.input ?? undefined,
|
|
||||||
)
|
|
||||||
: (response.updatedInput ?? pending.request.input ?? {});
|
|
||||||
pending.resolve({
|
|
||||||
behavior: "allow",
|
|
||||||
updatedInput,
|
|
||||||
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveDeniedPermission(
|
|
||||||
pending: PendingPermission,
|
|
||||||
response: Extract<AgentPermissionResponse, { behavior: "deny" }>,
|
|
||||||
): void {
|
|
||||||
if (pending.request.kind === "tool") {
|
|
||||||
this.pushToolCall(
|
|
||||||
mapClaudeFailedToolCall({
|
|
||||||
name: pending.request.name,
|
|
||||||
callId:
|
|
||||||
(typeof pending.request.metadata?.toolUseId === "string"
|
|
||||||
? pending.request.metadata.toolUseId
|
|
||||||
: null) ?? pending.request.id,
|
|
||||||
input: pending.request.input ?? null,
|
|
||||||
output: null,
|
|
||||||
error: { message: response.message ?? "Permission denied" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
pending.resolve({
|
|
||||||
behavior: "deny",
|
|
||||||
message: response.message ?? "Permission request denied",
|
|
||||||
interrupt: response.interrupt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async respondToPlan(planId: string, response: AgentPlanResponse): Promise<void> {
|
|
||||||
const requestId = this.pendingPlans.get(planId);
|
|
||||||
if (!requestId) {
|
|
||||||
throw new Error(`No pending Claude plan with id '${planId}'`);
|
|
||||||
}
|
|
||||||
this.pendingPlans.delete(planId);
|
|
||||||
|
|
||||||
if (response.actionId === "implement" || response.actionId === "implement_resume") {
|
|
||||||
await this.respondToPermission(
|
|
||||||
requestId,
|
|
||||||
{ behavior: "allow", selectedActionId: response.actionId },
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.actionId === "reject") {
|
|
||||||
await this.respondToPermission(
|
|
||||||
requestId,
|
|
||||||
{ behavior: "deny", selectedActionId: response.actionId, message: response.feedback },
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unknown Claude plan action '${response.actionId}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
describePersistence(): AgentPersistenceHandle | null {
|
describePersistence(): AgentPersistenceHandle | null {
|
||||||
if (this.persistence) {
|
if (this.persistence) {
|
||||||
return this.persistence;
|
return this.persistence;
|
||||||
@@ -3843,26 +3789,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
metadata: Object.keys(metadata).length ? metadata : undefined,
|
metadata: Object.keys(metadata).length ? metadata : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (kind === "plan" && typeof input.plan === "string") {
|
this.pushEvent({
|
||||||
const planId = `plan-${randomUUID()}`;
|
type: "permission_requested",
|
||||||
this.pendingPlans.set(planId, requestId);
|
provider: "claude",
|
||||||
this.pushEvent({
|
request,
|
||||||
type: "timeline",
|
});
|
||||||
provider: "claude",
|
|
||||||
item: {
|
|
||||||
type: "plan",
|
|
||||||
planId,
|
|
||||||
text: input.plan,
|
|
||||||
actions: buildClaudePlanActions(this.planResumeMode),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.pushEvent({
|
|
||||||
type: "permission_requested",
|
|
||||||
provider: "claude",
|
|
||||||
request,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return await new Promise<PermissionResult>((resolve, reject) => {
|
return await new Promise<PermissionResult>((resolve, reject) => {
|
||||||
const cleanupFns: Array<() => void> = [];
|
const cleanupFns: Array<() => void> = [];
|
||||||
@@ -3879,11 +3810,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
const abortHandler = () => {
|
const abortHandler = () => {
|
||||||
this.pendingPermissions.delete(requestId);
|
this.pendingPermissions.delete(requestId);
|
||||||
for (const [planId, pendingRequestId] of this.pendingPlans) {
|
|
||||||
if (pendingRequestId === requestId) {
|
|
||||||
this.pendingPlans.delete(planId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(new Error("Permission request aborted"));
|
reject(new Error("Permission request aborted"));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type CodexTestSession = AgentSession & {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
currentThreadId: string | null;
|
currentThreadId: string | null;
|
||||||
activeForegroundTurnId: string | null;
|
activeForegroundTurnId: string | null;
|
||||||
|
activeAppServerTurnId: string | null;
|
||||||
client: CodexClientLike | null;
|
client: CodexClientLike | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ function createSession(
|
|||||||
session.connected = true;
|
session.connected = true;
|
||||||
session.currentThreadId = "test-thread";
|
session.currentThreadId = "test-thread";
|
||||||
session.activeForegroundTurnId = "test-turn";
|
session.activeForegroundTurnId = "test-turn";
|
||||||
|
session.activeAppServerTurnId = "test-turn";
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +397,12 @@ describe("Codex app-server provider", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("advertises steering support for app-server sessions", () => {
|
||||||
|
const session = createSession();
|
||||||
|
|
||||||
|
expect(session.capabilities.supportsSteering).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
|
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
|
||||||
const requests: Array<{ method: string; params: unknown }> = [];
|
const requests: Array<{ method: string; params: unknown }> = [];
|
||||||
const fakeClient: CodexClientLike = {
|
const fakeClient: CodexClientLike = {
|
||||||
@@ -880,6 +888,161 @@ describe("Codex app-server provider", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("steers against the app-server turn id returned by turn/start", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
const request = vi.fn(async (method: string) => {
|
||||||
|
if (method === "thread/loaded/list") {
|
||||||
|
return { data: ["test-thread"] };
|
||||||
|
}
|
||||||
|
if (method === "turn/start") {
|
||||||
|
return { turn: { id: "app-server-turn-from-start" } };
|
||||||
|
}
|
||||||
|
if (method === "turn/steer") {
|
||||||
|
return { turnId: "app-server-turn-from-start" };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${method}`);
|
||||||
|
});
|
||||||
|
session.activeForegroundTurnId = null;
|
||||||
|
session.client = createStub<CodexClientLike>({ request });
|
||||||
|
|
||||||
|
const started = await session.startTurn("Start the turn");
|
||||||
|
expect(started.turnId).not.toBe("app-server-turn-from-start");
|
||||||
|
|
||||||
|
await session.steerTurn!("Steer the active turn");
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledWith(
|
||||||
|
"turn/steer",
|
||||||
|
expect.objectContaining({
|
||||||
|
expectedTurnId: "app-server-turn-from-start",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waits for the app-server turn id before steering Codex", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
const request = vi.fn(async (method: string) => {
|
||||||
|
if (method === "thread/loaded/list") {
|
||||||
|
return { data: ["test-thread"] };
|
||||||
|
}
|
||||||
|
if (method === "turn/start") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (method === "turn/steer") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${method}`);
|
||||||
|
});
|
||||||
|
session.activeForegroundTurnId = null;
|
||||||
|
session.client = createStub<CodexClientLike>({ request });
|
||||||
|
|
||||||
|
await session.startTurn("Start the turn");
|
||||||
|
await expect(session.steerTurn!("Too early")).rejects.toThrow(
|
||||||
|
"Cannot steer Codex turn without an active app-server turn",
|
||||||
|
);
|
||||||
|
|
||||||
|
asInternals(session).handleNotification("turn/started", {
|
||||||
|
turn: { id: "app-server-turn-from-notification" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.steerTurn!("Steer after notification");
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledWith(
|
||||||
|
"turn/steer",
|
||||||
|
expect.objectContaining({
|
||||||
|
expectedTurnId: "app-server-turn-from-notification",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends turn/steer with thread id, built input, and active foreground turn id", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
const request = vi.fn(async (method: string) => {
|
||||||
|
if (method === "turn/steer") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${method}`);
|
||||||
|
});
|
||||||
|
session.client = createStub<CodexClientLike>({ request });
|
||||||
|
session.currentThreadId = "thread-for-steer";
|
||||||
|
session.activeForegroundTurnId = "paseo-foreground-turn-for-steer";
|
||||||
|
session.activeAppServerTurnId = "app-server-turn-for-steer";
|
||||||
|
|
||||||
|
await session.steerTurn!("Use this guidance next.");
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledTimes(1);
|
||||||
|
expect(request).toHaveBeenCalledWith("turn/steer", {
|
||||||
|
threadId: "thread-for-steer",
|
||||||
|
expectedTurnId: "app-server-turn-for-steer",
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Use this guidance next.",
|
||||||
|
text_elements: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not start a new turn when steering Codex", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
const request = vi.fn(async (method: string) => {
|
||||||
|
if (method === "turn/steer") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${method}`);
|
||||||
|
});
|
||||||
|
session.client = createStub<CodexClientLike>({ request });
|
||||||
|
|
||||||
|
await session.steerTurn!("Keep going with this constraint.");
|
||||||
|
|
||||||
|
expect(request.mock.calls.some(([method]) => method === "turn/start")).toBe(false);
|
||||||
|
expect(session.activeForegroundTurnId).toBe("test-turn");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails Codex steering without an initialized client", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
session.client = null;
|
||||||
|
session.connected = true;
|
||||||
|
|
||||||
|
await expect(session.steerTurn!("hello")).rejects.toThrow("Codex client not initialized");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails Codex steering without a current thread", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
session.currentThreadId = null;
|
||||||
|
session.client = createStub<CodexClientLike>({
|
||||||
|
request: vi.fn(async () => ({})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(session.steerTurn!("hello")).rejects.toThrow(
|
||||||
|
"Cannot steer Codex turn without an active thread",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails Codex steering without an active foreground turn", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
session.activeForegroundTurnId = null;
|
||||||
|
session.client = createStub<CodexClientLike>({
|
||||||
|
request: vi.fn(async () => ({})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(session.steerTurn!("hello")).rejects.toThrow(
|
||||||
|
"Cannot steer Codex turn without an active foreground turn",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails Codex steering without an active app-server turn id", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
session.activeAppServerTurnId = null;
|
||||||
|
session.client = createStub<CodexClientLike>({
|
||||||
|
request: vi.fn(async () => ({})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(session.steerTurn!("hello")).rejects.toThrow(
|
||||||
|
"Cannot steer Codex turn without an active app-server turn",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("resolves Codex skill slash commands into app-server skill input", async () => {
|
test("resolves Codex skill slash commands into app-server skill input", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const request = vi.fn(async (method: string) => {
|
const request = vi.fn(async (method: string) => {
|
||||||
@@ -1989,7 +2152,7 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("emits an actionable plan item after a successful Codex plan turn", () => {
|
test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true, fast_mode: true },
|
featureValues: { plan_mode: true, fast_mode: true },
|
||||||
});
|
});
|
||||||
@@ -2018,20 +2181,27 @@ describe("Codex app-server provider", () => {
|
|||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(events.at(-2)).toEqual({
|
expect(events.at(-2)).toEqual({
|
||||||
type: "timeline",
|
type: "permission_requested",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
turnId: "test-turn",
|
turnId: "test-turn",
|
||||||
item: expect.objectContaining({
|
request: expect.objectContaining({
|
||||||
type: "plan",
|
provider: "codex",
|
||||||
text: "- Inspect the existing auth flow\n- Implement the button behavior",
|
name: "CodexPlanApproval",
|
||||||
|
kind: "plan",
|
||||||
|
title: "Plan",
|
||||||
|
input: {
|
||||||
|
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
|
||||||
|
},
|
||||||
actions: [
|
actions: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "reject",
|
id: "reject",
|
||||||
label: "Reject",
|
label: "Reject",
|
||||||
|
behavior: "deny",
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "implement",
|
id: "implement",
|
||||||
label: "Implement",
|
label: "Implement",
|
||||||
|
behavior: "allow",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -2075,12 +2245,16 @@ describe("Codex app-server provider", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(events.at(-2)).toEqual({
|
expect(events.at(-2)).toEqual({
|
||||||
type: "timeline",
|
type: "permission_requested",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
turnId: "test-turn",
|
turnId: "test-turn",
|
||||||
item: expect.objectContaining({
|
request: expect.objectContaining({
|
||||||
type: "plan",
|
provider: "codex",
|
||||||
text: "- Inspect README\n- Add a short note",
|
name: "CodexPlanApproval",
|
||||||
|
kind: "plan",
|
||||||
|
input: {
|
||||||
|
plan: "- Inspect README\n- Add a short note",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2458,7 +2632,7 @@ describe("Codex app-server provider", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("responding to a Codex plan item disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
|
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true, fast_mode: true },
|
featureValues: { plan_mode: true, fast_mode: true },
|
||||||
});
|
});
|
||||||
@@ -2475,16 +2649,19 @@ describe("Codex app-server provider", () => {
|
|||||||
turn: { status: "completed", error: null },
|
turn: { status: "completed", error: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
const plan = events.find(
|
const request = events.find(
|
||||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
event.type === "timeline" && event.item.type === "plan",
|
event.type === "permission_requested" && event.request.kind === "plan",
|
||||||
);
|
);
|
||||||
expect(plan).toBeDefined();
|
expect(request).toBeDefined();
|
||||||
if (!plan || plan.item.type !== "plan") {
|
if (!request) {
|
||||||
throw new Error("Expected plan item");
|
throw new Error("Expected synthetic plan approval permission");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
|
const result = await session.respondToPermission(request.request.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement",
|
||||||
|
});
|
||||||
|
|
||||||
expect(asInternals(session).serviceTier).toBe("fast");
|
expect(asInternals(session).serviceTier).toBe("fast");
|
||||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||||
@@ -2498,10 +2675,18 @@ describe("Codex app-server provider", () => {
|
|||||||
expect(result!.followUpPrompt).toEqual(
|
expect(result!.followUpPrompt).toEqual(
|
||||||
expect.stringContaining("The user approved the plan. Implement it now."),
|
expect.stringContaining("The user approved the plan. Implement it now."),
|
||||||
);
|
);
|
||||||
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_resolved" }));
|
expect(events.at(-1)).toEqual({
|
||||||
|
type: "permission_resolved",
|
||||||
|
provider: "codex",
|
||||||
|
requestId: request.request.id,
|
||||||
|
resolution: {
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("responding to a Codex plan item keeps fast mode disabled when it started disabled", async () => {
|
test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true, fast_mode: false },
|
featureValues: { plan_mode: true, fast_mode: false },
|
||||||
});
|
});
|
||||||
@@ -2518,16 +2703,19 @@ describe("Codex app-server provider", () => {
|
|||||||
turn: { status: "completed", error: null },
|
turn: { status: "completed", error: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
const plan = events.find(
|
const request = events.find(
|
||||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
event.type === "timeline" && event.item.type === "plan",
|
event.type === "permission_requested" && event.request.kind === "plan",
|
||||||
);
|
);
|
||||||
expect(plan).toBeDefined();
|
expect(request).toBeDefined();
|
||||||
if (!plan || plan.item.type !== "plan") {
|
if (!request) {
|
||||||
throw new Error("Expected plan item");
|
throw new Error("Expected synthetic plan approval permission");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
|
const result = await session.respondToPermission(request.request.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement",
|
||||||
|
});
|
||||||
|
|
||||||
expect(asInternals(session).serviceTier).toBeNull();
|
expect(asInternals(session).serviceTier).toBeNull();
|
||||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||||
@@ -2583,16 +2771,19 @@ describe("Codex app-server provider", () => {
|
|||||||
turn: { status: "completed", error: null },
|
turn: { status: "completed", error: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
const plan = events.find(
|
const permissionRequest = events.find(
|
||||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
event.type === "timeline" && event.item.type === "plan",
|
event.type === "permission_requested" && event.request.kind === "plan",
|
||||||
);
|
);
|
||||||
expect(plan).toBeDefined();
|
expect(permissionRequest).toBeDefined();
|
||||||
if (!plan || plan.item.type !== "plan") {
|
if (!permissionRequest) {
|
||||||
throw new Error("Expected plan item");
|
throw new Error("Expected synthetic plan approval permission");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
|
const result = await session.respondToPermission(permissionRequest.request.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement",
|
||||||
|
});
|
||||||
expect(result?.followUpPrompt).toEqual(expect.any(String));
|
expect(result?.followUpPrompt).toEqual(expect.any(String));
|
||||||
|
|
||||||
await session.startTurn(result!.followUpPrompt!);
|
await session.startTurn(result!.followUpPrompt!);
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ import {
|
|||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
type AgentModelDefinition,
|
type AgentModelDefinition,
|
||||||
type AgentPlanAction,
|
|
||||||
type AgentPlanResponse,
|
|
||||||
type AgentPlanResult,
|
|
||||||
type McpServerConfig,
|
type McpServerConfig,
|
||||||
type AgentPersistenceHandle,
|
type AgentPersistenceHandle,
|
||||||
type AgentPermissionRequest,
|
type AgentPermissionRequest,
|
||||||
@@ -44,7 +41,6 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||||
import { planItemFromToolCall } from "../plan-files.js";
|
|
||||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||||
import { curateAgentActivity } from "../activity-curator.js";
|
import { curateAgentActivity } from "../activity-curator.js";
|
||||||
import {
|
import {
|
||||||
@@ -175,6 +171,7 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsRewindConversation: true,
|
supportsRewindConversation: true,
|
||||||
supportsRewindFiles: false,
|
supportsRewindFiles: false,
|
||||||
supportsRewindBoth: false,
|
supportsRewindBoth: false,
|
||||||
|
supportsSteering: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CODEX_MODES: AgentMode[] = [
|
const CODEX_MODES: AgentMode[] = [
|
||||||
@@ -945,10 +942,6 @@ function buildPlanPermissionActions(options?: {
|
|||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPlanActions(): AgentPlanAction[] {
|
|
||||||
return buildPlanPermissionActions().map(({ id, label, variant }) => ({ id, label, variant }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCodexPlanImplementationPrompt(planText: string): string {
|
function buildCodexPlanImplementationPrompt(planText: string): string {
|
||||||
const normalizedPlan = normalizePlanMarkdown(planText);
|
const normalizedPlan = normalizePlanMarkdown(planText);
|
||||||
if (!normalizedPlan) {
|
if (!normalizedPlan) {
|
||||||
@@ -2896,6 +2889,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
private nextTurnOrdinal = 0;
|
private nextTurnOrdinal = 0;
|
||||||
private activeForegroundTurnId: string | null = null;
|
private activeForegroundTurnId: string | null = null;
|
||||||
|
private activeAppServerTurnId: string | null = null;
|
||||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||||
private serviceTier: "fast" | null = null;
|
private serviceTier: "fast" | null = null;
|
||||||
private planModeEnabled = false;
|
private planModeEnabled = false;
|
||||||
@@ -2933,7 +2927,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
||||||
private readonly userMessageTurnIndexes = new Map<string, number>();
|
private readonly userMessageTurnIndexes = new Map<string, number>();
|
||||||
private readonly userMessageTurnIds: string[] = [];
|
private readonly userMessageTurnIds: string[] = [];
|
||||||
private pendingPlans = new Map<string, { text: string }>();
|
|
||||||
private pendingManualCompactionStarts = 0;
|
private pendingManualCompactionStarts = 0;
|
||||||
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
||||||
// Codex can report one completed compaction through both channels:
|
// Codex can report one completed compaction through both channels:
|
||||||
@@ -3196,36 +3189,30 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem): void {
|
private emitSyntheticPlanApprovalRequest(planText: string): void {
|
||||||
void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() })
|
const requestId = `permission-${randomUUID()}`;
|
||||||
.then((planItem) => {
|
const request: AgentPermissionRequest = {
|
||||||
if (planItem) {
|
id: requestId,
|
||||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: planItem });
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitPlanApprovalItem(planText: string): void {
|
|
||||||
const text = normalizePlanMarkdown(planText);
|
|
||||||
if (!text) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const planId = `plan-${randomUUID()}`;
|
|
||||||
this.pendingPlans.set(planId, { text });
|
|
||||||
this.emitEvent({
|
|
||||||
type: "timeline",
|
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: {
|
name: "CodexPlanApproval",
|
||||||
type: "plan",
|
kind: "plan",
|
||||||
planId,
|
title: "Plan",
|
||||||
text,
|
description: "Review the proposed plan before implementation starts.",
|
||||||
actions: buildPlanActions(),
|
input: { plan: planText },
|
||||||
|
actions: buildPlanPermissionActions(),
|
||||||
|
metadata: {
|
||||||
|
planText,
|
||||||
|
source: "codex_plan_approval",
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pendingPermissions.set(requestId, request);
|
||||||
|
this.pendingPermissionHandlers.set(requestId, {
|
||||||
|
resolve: () => undefined,
|
||||||
|
kind: "plan",
|
||||||
|
planText,
|
||||||
});
|
});
|
||||||
|
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3496,6 +3483,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildEffectivePromptInput(prompt: AgentPromptInput): Promise<CodexPromptInput> {
|
||||||
|
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||||
|
return slashCommand
|
||||||
|
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
||||||
|
: prompt;
|
||||||
|
}
|
||||||
|
|
||||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||||
return runProviderTurn({
|
return runProviderTurn({
|
||||||
prompt,
|
prompt,
|
||||||
@@ -3528,10 +3522,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
throw new Error("Codex client not initialized");
|
throw new Error("Codex client not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
const effectivePrompt = await this.buildEffectivePromptInput(prompt);
|
||||||
const effectivePrompt = slashCommand
|
|
||||||
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
|
||||||
: prompt;
|
|
||||||
|
|
||||||
if (this.currentThreadId) {
|
if (this.currentThreadId) {
|
||||||
await this.ensureThreadLoaded();
|
await this.ensureThreadLoaded();
|
||||||
@@ -3543,6 +3534,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
const turnId = this.createTurnId();
|
const turnId = this.createTurnId();
|
||||||
this.activeForegroundTurnId = turnId;
|
this.activeForegroundTurnId = turnId;
|
||||||
|
this.activeAppServerTurnId = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logTurnStartSummary({
|
this.logTurnStartSummary({
|
||||||
@@ -3554,9 +3546,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
||||||
hasCodexConfig: turnStart.hasCodexConfig,
|
hasCodexConfig: turnStart.hasCodexConfig,
|
||||||
});
|
});
|
||||||
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
|
const response = await this.client.request(
|
||||||
|
"turn/start",
|
||||||
|
turnStart.params,
|
||||||
|
TURN_START_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
this.rememberStartedAppServerTurn(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
|
this.activeAppServerTurnId = null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3598,6 +3596,36 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private rememberStartedAppServerTurn(response: unknown): void {
|
||||||
|
const record = toObjectRecord(response);
|
||||||
|
const turn = toObjectRecord(record?.turn);
|
||||||
|
this.activeAppServerTurnId = typeof turn?.id === "string" ? turn.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async steerTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<void> {
|
||||||
|
await this.connect();
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error("Codex client not initialized");
|
||||||
|
}
|
||||||
|
if (!this.currentThreadId) {
|
||||||
|
throw new Error("Cannot steer Codex turn without an active thread");
|
||||||
|
}
|
||||||
|
if (!this.activeForegroundTurnId) {
|
||||||
|
throw new Error("Cannot steer Codex turn without an active foreground turn");
|
||||||
|
}
|
||||||
|
if (!this.activeAppServerTurnId) {
|
||||||
|
throw new Error("Cannot steer Codex turn without an active app-server turn");
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectivePrompt = await this.buildEffectivePromptInput(prompt);
|
||||||
|
const input = await this.buildUserInput(effectivePrompt);
|
||||||
|
await this.client.request("turn/steer", {
|
||||||
|
threadId: this.currentThreadId,
|
||||||
|
input,
|
||||||
|
expectedTurnId: this.activeAppServerTurnId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||||
this.subscribers.add(callback);
|
this.subscribers.add(callback);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -3785,29 +3813,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
pending.resolve({ answers: {} });
|
pending.resolve({ answers: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
async respondToPlan(
|
|
||||||
planId: string,
|
|
||||||
response: AgentPlanResponse,
|
|
||||||
): Promise<AgentPlanResult | void> {
|
|
||||||
const pending = this.pendingPlans.get(planId);
|
|
||||||
if (!pending) {
|
|
||||||
throw new Error(`No pending Codex app-server plan with id '${planId}'`);
|
|
||||||
}
|
|
||||||
this.pendingPlans.delete(planId);
|
|
||||||
|
|
||||||
if (response.actionId === "implement" || response.actionId === "implement_resume") {
|
|
||||||
return {
|
|
||||||
followUpPrompt: this.preparePlanImplementation({ planText: pending.text }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.actionId === "reject") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unknown Codex plan action '${response.actionId}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePlanPermissionResponse(params: {
|
private handlePlanPermissionResponse(params: {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
response: AgentPermissionResponse;
|
response: AgentPermissionResponse;
|
||||||
@@ -3958,6 +3963,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.resolvedPermissionRequests.clear();
|
this.resolvedPermissionRequests.clear();
|
||||||
this.subscribers.clear();
|
this.subscribers.clear();
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
|
this.activeAppServerTurnId = null;
|
||||||
if (this.client) {
|
if (this.client) {
|
||||||
await this.client.dispose();
|
await this.client.dispose();
|
||||||
}
|
}
|
||||||
@@ -4579,6 +4585,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.currentTurnId = parsed.turnId;
|
this.currentTurnId = parsed.turnId;
|
||||||
|
this.activeAppServerTurnId = parsed.turnId;
|
||||||
this.resetTurnTrackingState();
|
this.resetTurnTrackingState();
|
||||||
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
|
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
|
||||||
}
|
}
|
||||||
@@ -4607,7 +4614,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
||||||
} else {
|
} else {
|
||||||
if (this.planModeEnabled && this.latestPlanResult?.text) {
|
if (this.planModeEnabled && this.latestPlanResult?.text) {
|
||||||
this.emitPlanApprovalItem(this.latestPlanResult.text);
|
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
|
||||||
}
|
}
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "turn_completed",
|
type: "turn_completed",
|
||||||
@@ -4616,6 +4623,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
|
this.activeAppServerTurnId = null;
|
||||||
this.resetTurnTrackingState();
|
this.resetTurnTrackingState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4921,9 +4929,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
||||||
}
|
}
|
||||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||||
if (timelineItem.type === "tool_call") {
|
|
||||||
this.emitPlanFileItemFromToolCall(timelineItem);
|
|
||||||
}
|
|
||||||
if (timelineItem.type === "assistant_message") {
|
if (timelineItem.type === "assistant_message") {
|
||||||
this.pendingAssistantMessageBoundary = true;
|
this.pendingAssistantMessageBoundary = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("Codex app-server provider (real) plan mode", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("maps gpt-5.4 markdown plans to a normalized plan item instead of todo items", async () => {
|
test("maps gpt-5.4 markdown plans to a plan tool call instead of todo items", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||||
|
|
||||||
@@ -50,16 +50,18 @@ describe("Codex app-server provider (real) plan mode", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const planItem = result.timeline.find((item) => item.type === "plan");
|
const planCall = result.timeline.find(
|
||||||
|
(item) => item.type === "tool_call" && item.detail.type === "plan",
|
||||||
|
);
|
||||||
|
|
||||||
expect(planItem).toBeDefined();
|
expect(planCall).toBeDefined();
|
||||||
if (!planItem || planItem.type !== "plan") {
|
if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") {
|
||||||
throw new Error("Expected a normalized plan item");
|
throw new Error("Expected a plan tool call");
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(planItem.text).toContain("Login");
|
expect(planCall.detail.text).toContain("Login");
|
||||||
expect(planItem.text).toContain("- ");
|
expect(planCall.detail.text).toContain("- ");
|
||||||
expect(planItem.actions?.some((action) => action.id === "implement")).toBe(true);
|
expect(result.finalText).toBe(planCall.detail.text);
|
||||||
} finally {
|
} finally {
|
||||||
await session.close();
|
await session.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ import {
|
|||||||
resolveProviderLaunch,
|
resolveProviderLaunch,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { isPlanFilePath, planItemFromToolCall } from "../plan-files.js";
|
|
||||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
import { execCommand } from "../../../utils/spawn.js";
|
import { execCommand } from "../../../utils/spawn.js";
|
||||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||||
@@ -2155,22 +2154,6 @@ function appendOpenCodeToolCallTimelineItem(
|
|||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
item: timelineItem,
|
item: timelineItem,
|
||||||
});
|
});
|
||||||
if (
|
|
||||||
timelineItem.status === "completed" &&
|
|
||||||
timelineItem.detail.type === "write" &&
|
|
||||||
timelineItem.detail.content?.trim() &&
|
|
||||||
isPlanFilePath(timelineItem.detail.filePath)
|
|
||||||
) {
|
|
||||||
events.push({
|
|
||||||
type: "timeline",
|
|
||||||
provider: "opencode",
|
|
||||||
item: {
|
|
||||||
type: "plan",
|
|
||||||
planId: `plan-file:${timelineItem.detail.filePath}`,
|
|
||||||
text: timelineItem.detail.content.trim(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) {
|
if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) {
|
||||||
flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events);
|
flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events);
|
||||||
}
|
}
|
||||||
@@ -3230,28 +3213,9 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.notifySubscribers(e, turnId);
|
this.notifySubscribers(e, turnId);
|
||||||
if (e.type === "timeline" && e.item.type === "tool_call") {
|
|
||||||
this.emitPlanFileItemFromToolCall(e.item, turnId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem, turnId: string): void {
|
|
||||||
void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() })
|
|
||||||
.then((planItem) => {
|
|
||||||
if (planItem) {
|
|
||||||
this.notifySubscribers(
|
|
||||||
{ type: "timeline", provider: "opencode", item: planItem },
|
|
||||||
turnId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private finishForegroundTurn(
|
private finishForegroundTurn(
|
||||||
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
||||||
turnId: string,
|
turnId: string,
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import pino from "pino";
|
|
||||||
|
|
||||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
|
||||||
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
|
||||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
|
||||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
|
||||||
import { isProviderAvailable } from "./agent-configs.js";
|
|
||||||
import type { PlanTimelineItem } from "../agent/agent-sdk-types.js";
|
|
||||||
|
|
||||||
function tmpCwd(): string {
|
|
||||||
return mkdtempSync(path.join(tmpdir(), "daemon-plans-"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForPlanMessage(
|
|
||||||
collector: MessageCollector,
|
|
||||||
agentId: string,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<PlanTimelineItem> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
const message = collector.messages.find((candidate) => {
|
|
||||||
if (candidate.type !== "agent_stream") return false;
|
|
||||||
if (candidate.payload.agentId !== agentId) return false;
|
|
||||||
return (
|
|
||||||
candidate.payload.event.type === "timeline" &&
|
|
||||||
candidate.payload.event.item.type === "plan"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (message?.type === "agent_stream") {
|
|
||||||
const event = message.payload.event;
|
|
||||||
if (event.type === "timeline" && event.item.type === "plan") {
|
|
||||||
clearInterval(timer);
|
|
||||||
resolve(event.item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Date.now() > deadline) {
|
|
||||||
clearInterval(timer);
|
|
||||||
reject(new Error(`Timed out waiting for plan item after ${timeoutMs}ms`));
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("daemon E2E - first-class plans", () => {
|
|
||||||
let ctx: DaemonTestContext;
|
|
||||||
let collector: MessageCollector;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
ctx = await createDaemonTestContext();
|
|
||||||
collector = createMessageCollector(ctx.client);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
collector.unsubscribe();
|
|
||||||
await ctx.cleanup();
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
test("surfaces an actionable plan and routes the response through the daemon", async () => {
|
|
||||||
const cwd = tmpCwd();
|
|
||||||
try {
|
|
||||||
const agent = await ctx.client.createAgent({
|
|
||||||
provider: "codex",
|
|
||||||
cwd,
|
|
||||||
title: "Plan E2E",
|
|
||||||
modeId: "full-access",
|
|
||||||
});
|
|
||||||
|
|
||||||
collector.clear();
|
|
||||||
await ctx.client.sendMessage(agent.id, "Emit an actionable plan.");
|
|
||||||
const finalState = await ctx.client.waitForFinish(agent.id, 5_000);
|
|
||||||
expect(finalState.status).toBe("idle");
|
|
||||||
|
|
||||||
const planMessage = collector.messages.find((message) => {
|
|
||||||
if (message.type !== "agent_stream") return false;
|
|
||||||
if (message.payload.agentId !== agent.id) return false;
|
|
||||||
return (
|
|
||||||
message.payload.event.type === "timeline" && message.payload.event.item.type === "plan"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(planMessage?.type).toBe("agent_stream");
|
|
||||||
if (planMessage?.type !== "agent_stream") {
|
|
||||||
throw new Error("Expected plan stream message");
|
|
||||||
}
|
|
||||||
const event = planMessage.payload.event;
|
|
||||||
if (event.type !== "timeline" || event.item.type !== "plan") {
|
|
||||||
throw new Error("Expected normalized plan item");
|
|
||||||
}
|
|
||||||
expect(event.item.actions).toEqual([
|
|
||||||
{ id: "implement", label: "Implement", variant: "primary" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
|
||||||
direction: "tail",
|
|
||||||
limit: 0,
|
|
||||||
projection: "canonical",
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
timeline.entries.some(
|
|
||||||
(entry) => entry.item.type === "plan" && entry.item.planId === event.item.planId,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
const response = await ctx.client.respondToPlan(agent.id, event.item.planId, {
|
|
||||||
actionId: "implement",
|
|
||||||
});
|
|
||||||
expect(response).toMatchObject({
|
|
||||||
agentId: agent.id,
|
|
||||||
planId: event.item.planId,
|
|
||||||
ok: true,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
test("surfaces a plan file as a non-actionable plan", async () => {
|
|
||||||
const cwd = tmpCwd();
|
|
||||||
try {
|
|
||||||
const agent = await ctx.client.createAgent({
|
|
||||||
provider: "opencode",
|
|
||||||
cwd,
|
|
||||||
title: "Plan File E2E",
|
|
||||||
modeId: "full-access",
|
|
||||||
});
|
|
||||||
|
|
||||||
collector.clear();
|
|
||||||
await ctx.client.sendMessage(agent.id, "Emit a plan file.");
|
|
||||||
await ctx.client.waitForFinish(agent.id, 5_000);
|
|
||||||
|
|
||||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
|
||||||
direction: "tail",
|
|
||||||
limit: 0,
|
|
||||||
projection: "canonical",
|
|
||||||
});
|
|
||||||
const plan = timeline.entries.find(
|
|
||||||
(entry) =>
|
|
||||||
entry.item.type === "plan" && entry.item.planId === "plan-file:.paseo/plans/fake.md",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(plan?.item).toEqual({
|
|
||||||
type: "plan",
|
|
||||||
planId: "plan-file:.paseo/plans/fake.md",
|
|
||||||
text: "# File plan\n\n- From disk",
|
|
||||||
});
|
|
||||||
const response = await ctx.client.respondToPlan(agent.id, "plan-file:.paseo/plans/fake.md", {
|
|
||||||
actionId: "implement",
|
|
||||||
});
|
|
||||||
expect(response.ok).toBe(false);
|
|
||||||
expect(response.error).toContain("No pending fake plan");
|
|
||||||
} finally {
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}, 30_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("daemon E2E - first-class plans with real providers", () => {
|
|
||||||
test("real Codex plan mode surfaces a normalized actionable plan", async (context) => {
|
|
||||||
if (!(await isProviderAvailable("codex"))) {
|
|
||||||
context.skip();
|
|
||||||
}
|
|
||||||
|
|
||||||
const cwd = tmpCwd();
|
|
||||||
const logger = pino({ level: "silent" });
|
|
||||||
const daemon = await createTestPaseoDaemon({
|
|
||||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
await client.fetchAgents({ subscribe: { subscriptionId: "real-codex-plan" } });
|
|
||||||
const agent = await client.createAgent({
|
|
||||||
provider: "codex",
|
|
||||||
cwd,
|
|
||||||
title: "Real Codex Plan E2E",
|
|
||||||
modeId: "auto",
|
|
||||||
model: "gpt-5.4",
|
|
||||||
thinkingOptionId: "medium",
|
|
||||||
featureValues: { plan_mode: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.sendMessage(
|
|
||||||
agent.id,
|
|
||||||
"You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.",
|
|
||||||
);
|
|
||||||
await client.waitForFinish(agent.id, 240_000);
|
|
||||||
|
|
||||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
|
||||||
direction: "tail",
|
|
||||||
limit: 0,
|
|
||||||
projection: "canonical",
|
|
||||||
});
|
|
||||||
const plan = timeline.entries.find((entry) => entry.item.type === "plan");
|
|
||||||
|
|
||||||
expect(plan?.item.type).toBe("plan");
|
|
||||||
if (!plan || plan.item.type !== "plan") {
|
|
||||||
throw new Error("Expected normalized plan item");
|
|
||||||
}
|
|
||||||
expect(plan.item.text).toContain("Login");
|
|
||||||
expect(plan.item.actions?.some((action) => action.id === "implement")).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await daemon.close();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}, 300_000);
|
|
||||||
|
|
||||||
test("real Claude plan mode surfaces a normalized actionable plan", async (context) => {
|
|
||||||
if (!(await isProviderAvailable("claude"))) {
|
|
||||||
context.skip();
|
|
||||||
}
|
|
||||||
|
|
||||||
const cwd = tmpCwd();
|
|
||||||
const logger = pino({ level: "silent" });
|
|
||||||
const daemon = await createTestPaseoDaemon({
|
|
||||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
|
||||||
const collector = createMessageCollector(client);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
await client.fetchAgents({ subscribe: { subscriptionId: "real-claude-plan" } });
|
|
||||||
const agent = await client.createAgent({
|
|
||||||
provider: "claude",
|
|
||||||
cwd,
|
|
||||||
title: "Real Claude Plan E2E",
|
|
||||||
modeId: "plan",
|
|
||||||
model: "haiku",
|
|
||||||
});
|
|
||||||
|
|
||||||
collector.clear();
|
|
||||||
await client.sendMessage(
|
|
||||||
agent.id,
|
|
||||||
[
|
|
||||||
"Create a short implementation plan for a login screen.",
|
|
||||||
"Use plan mode and call ExitPlanMode with a markdown plan.",
|
|
||||||
"Do not edit files.",
|
|
||||||
].join(" "),
|
|
||||||
);
|
|
||||||
|
|
||||||
const plan = await waitForPlanMessage(collector, agent.id, 120_000);
|
|
||||||
expect(plan.text).toContain("login");
|
|
||||||
expect(plan.actions?.some((action) => action.id === "implement")).toBe(true);
|
|
||||||
|
|
||||||
const snapshot = await client.fetchAgent(agent.id);
|
|
||||||
expect(snapshot.agent?.pendingPermissions ?? []).toEqual([]);
|
|
||||||
|
|
||||||
const response = await client.respondToPlan(agent.id, plan.planId, { actionId: "reject" });
|
|
||||||
expect(response).toMatchObject({
|
|
||||||
agentId: agent.id,
|
|
||||||
planId: plan.planId,
|
|
||||||
ok: true,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
collector.unsubscribe();
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await daemon.close();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}, 180_000);
|
|
||||||
});
|
|
||||||
@@ -63,49 +63,6 @@ describe("serializeAgentStreamEvent", () => {
|
|||||||
expect(serialized.item.messageId).toBe("m1");
|
expect(serialized.item.messageId).toBe("m1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts normalized plan timeline items", () => {
|
|
||||||
const event: AgentStreamEvent = {
|
|
||||||
type: "timeline",
|
|
||||||
provider: "codex",
|
|
||||||
item: {
|
|
||||||
type: "plan",
|
|
||||||
planId: "plan-1",
|
|
||||||
text: "# Plan\n\n- Ship it",
|
|
||||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const serialized = serializeAgentStreamEvent(event);
|
|
||||||
|
|
||||||
expect(serialized).toMatchObject({
|
|
||||||
type: "timeline",
|
|
||||||
item: {
|
|
||||||
type: "plan",
|
|
||||||
planId: "plan-1",
|
|
||||||
text: "# Plan\n\n- Ship it",
|
|
||||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts plan response requests", () => {
|
|
||||||
const parsed = SessionInboundMessageSchema.parse({
|
|
||||||
type: "agent.plan.respond.request",
|
|
||||||
agentId: "agent-1",
|
|
||||||
planId: "plan-1",
|
|
||||||
actionId: "implement",
|
|
||||||
requestId: "req-plan-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed).toMatchObject({
|
|
||||||
type: "agent.plan.respond.request",
|
|
||||||
agentId: "agent-1",
|
|
||||||
planId: "plan-1",
|
|
||||||
actionId: "implement",
|
|
||||||
requestId: "req-plan-1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes canonical tool_call payloads through unchanged", () => {
|
test("passes canonical tool_call payloads through unchanged", () => {
|
||||||
const event: AgentStreamEvent = {
|
const event: AgentStreamEvent = {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js";
|
|||||||
import {
|
import {
|
||||||
formatSystemNotificationPrompt,
|
formatSystemNotificationPrompt,
|
||||||
sendPromptToAgent,
|
sendPromptToAgent,
|
||||||
startAgentRun,
|
|
||||||
waitForAgentRunStartWithTimeout,
|
waitForAgentRunStartWithTimeout,
|
||||||
unarchiveAgentState,
|
unarchiveAgentState,
|
||||||
} from "./agent/agent-prompt.js";
|
} from "./agent/agent-prompt.js";
|
||||||
@@ -143,8 +142,6 @@ import {
|
|||||||
type AgentPromptInput,
|
type AgentPromptInput,
|
||||||
type AgentRunOptions,
|
type AgentRunOptions,
|
||||||
type AgentSessionConfig,
|
type AgentSessionConfig,
|
||||||
type AgentStreamEvent,
|
|
||||||
type AgentTimelineItem,
|
|
||||||
type ProviderSnapshotEntry,
|
type ProviderSnapshotEntry,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
@@ -719,45 +716,6 @@ function parseClientCapabilities(
|
|||||||
return new Set(result);
|
return new Set(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectTimelineItemForClient(
|
|
||||||
item: AgentTimelineItem,
|
|
||||||
capabilities: ReadonlySet<ClientCapability>,
|
|
||||||
): AgentTimelineItem {
|
|
||||||
if (item.type !== "plan" || capabilities.has(CLIENT_CAPS.firstClassPlans)) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
// COMPAT(firstClassPlans): added in v0.1.82, remove shim after 2026-11-28.
|
|
||||||
return {
|
|
||||||
type: "tool_call",
|
|
||||||
callId: item.planId,
|
|
||||||
name: "Plan",
|
|
||||||
status: "completed",
|
|
||||||
error: null,
|
|
||||||
detail: {
|
|
||||||
type: "plan",
|
|
||||||
text: item.text,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectAgentStreamEventForClient(
|
|
||||||
event: AgentStreamEvent,
|
|
||||||
capabilities: ReadonlySet<ClientCapability>,
|
|
||||||
): AgentStreamEvent {
|
|
||||||
if (event.type !== "timeline") {
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
const item = projectTimelineItemForClient(event.item, capabilities);
|
|
||||||
if (item === event.item) {
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
item,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session represents a single connected client session.
|
* Session represents a single connected client session.
|
||||||
* It owns all state management, orchestration logic, and message processing.
|
* It owns all state management, orchestration logic, and message processing.
|
||||||
@@ -1377,11 +1335,7 @@ export class Session {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectedEvent = projectAgentStreamEventForClient(
|
const serializedEvent = serializeAgentStreamEvent(event.event);
|
||||||
event.event,
|
|
||||||
this.clientCapabilities,
|
|
||||||
);
|
|
||||||
const serializedEvent = serializeAgentStreamEvent(projectedEvent);
|
|
||||||
if (!serializedEvent) {
|
if (!serializedEvent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1786,7 +1740,6 @@ export class Session {
|
|||||||
const promise =
|
const promise =
|
||||||
this.dispatchVoiceAndControlMessage(msg) ??
|
this.dispatchVoiceAndControlMessage(msg) ??
|
||||||
this.dispatchAgentRewindMessage(msg) ??
|
this.dispatchAgentRewindMessage(msg) ??
|
||||||
this.dispatchAgentPlanMessage(msg) ??
|
|
||||||
this.dispatchAgentLifecycleMessage(msg) ??
|
this.dispatchAgentLifecycleMessage(msg) ??
|
||||||
this.dispatchAgentConfigMessage(msg) ??
|
this.dispatchAgentConfigMessage(msg) ??
|
||||||
this.dispatchCheckoutMessage(msg) ??
|
this.dispatchCheckoutMessage(msg) ??
|
||||||
@@ -1798,13 +1751,6 @@ export class Session {
|
|||||||
if (promise) await promise;
|
if (promise) await promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispatchAgentPlanMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
||||||
if (msg.type === "agent.plan.respond.request") {
|
|
||||||
return this.handleAgentPlanRespondRequest(msg);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "voice_audio_chunk":
|
case "voice_audio_chunk":
|
||||||
@@ -4615,47 +4561,6 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAgentPlanRespondRequest(
|
|
||||||
msg: Extract<SessionInboundMessage, { type: "agent.plan.respond.request" }>,
|
|
||||||
): Promise<void> {
|
|
||||||
const { agentId, planId, actionId, feedback, requestId } = msg;
|
|
||||||
try {
|
|
||||||
const result = await this.agentManager.respondToPlan(agentId, planId, { actionId, feedback });
|
|
||||||
this.emit({
|
|
||||||
type: "agent.plan.respond.response",
|
|
||||||
payload: {
|
|
||||||
requestId,
|
|
||||||
agentId,
|
|
||||||
planId,
|
|
||||||
ok: true,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result?.followUpPrompt) {
|
|
||||||
startAgentRun(this.agentManager, agentId, result.followUpPrompt, this.sessionLogger, {
|
|
||||||
replaceRunning: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
this.sessionLogger.error(
|
|
||||||
{ err: error, agentId, planId, actionId },
|
|
||||||
"Failed to respond to plan",
|
|
||||||
);
|
|
||||||
this.emit({
|
|
||||||
type: "agent.plan.respond.response",
|
|
||||||
payload: {
|
|
||||||
requestId,
|
|
||||||
agentId,
|
|
||||||
planId,
|
|
||||||
ok: false,
|
|
||||||
error: message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCheckoutStatusRequest(
|
private async handleCheckoutStatusRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -7660,7 +7565,7 @@ export class Session {
|
|||||||
hasNewer,
|
hasNewer,
|
||||||
entries: entries.map((entry) => ({
|
entries: entries.map((entry) => ({
|
||||||
provider: snapshot.provider,
|
provider: snapshot.provider,
|
||||||
item: projectTimelineItemForClient(entry.item, this.clientCapabilities),
|
item: entry.item,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
seqStart: entry.seqStart,
|
seqStart: entry.seqStart,
|
||||||
seqEnd: entry.seqEnd,
|
seqEnd: entry.seqEnd,
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import type {
|
|||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentPlanResponse,
|
|
||||||
AgentPlanResult,
|
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentPromptInput,
|
AgentPromptInput,
|
||||||
AgentRunOptions,
|
AgentRunOptions,
|
||||||
@@ -306,7 +304,6 @@ class FakeAgentSession implements AgentSession {
|
|||||||
private memoryMarker: string | null = null;
|
private memoryMarker: string | null = null;
|
||||||
private pendingPermissions: AgentPermissionRequest[] = [];
|
private pendingPermissions: AgentPermissionRequest[] = [];
|
||||||
private permissionGate: Deferred<AgentPermissionResponse> | null = null;
|
private permissionGate: Deferred<AgentPermissionResponse> | null = null;
|
||||||
private pendingPlans = new Map<string, { text: string }>();
|
|
||||||
private readonly historyPath: string;
|
private readonly historyPath: string;
|
||||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
private nextTurnOrdinal = 0;
|
private nextTurnOrdinal = 0;
|
||||||
@@ -525,55 +522,6 @@ class FakeAgentSession implements AgentSession {
|
|||||||
this.notifySubscribers(completed);
|
this.notifySubscribers(completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async emitActionablePlanTurn(text: string): Promise<void> {
|
|
||||||
const planId = `fake-plan-${randomUUID()}`;
|
|
||||||
const planText = text.includes("custom plan body") ? "custom plan body" : "# Plan\n\n- Test it";
|
|
||||||
this.pendingPlans.set(planId, { text: planText });
|
|
||||||
|
|
||||||
const planEvent: AgentStreamEvent = {
|
|
||||||
type: "timeline",
|
|
||||||
provider: this.providerName,
|
|
||||||
item: {
|
|
||||||
type: "plan",
|
|
||||||
planId,
|
|
||||||
text: planText,
|
|
||||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await this.appendHistoryEvent(planEvent);
|
|
||||||
this.notifySubscribers(planEvent);
|
|
||||||
|
|
||||||
const completed: AgentStreamEvent = {
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: this.providerName,
|
|
||||||
usage: { inputTokens: 1, outputTokens: 1 },
|
|
||||||
};
|
|
||||||
await this.appendHistoryEvent(completed);
|
|
||||||
this.notifySubscribers(completed);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async emitPlanFileTurn(): Promise<void> {
|
|
||||||
const planEvent: AgentStreamEvent = {
|
|
||||||
type: "timeline",
|
|
||||||
provider: this.providerName,
|
|
||||||
item: {
|
|
||||||
type: "plan",
|
|
||||||
planId: "plan-file:.paseo/plans/fake.md",
|
|
||||||
text: "# File plan\n\n- From disk",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await this.appendHistoryEvent(planEvent);
|
|
||||||
this.notifySubscribers(planEvent);
|
|
||||||
|
|
||||||
const completed: AgentStreamEvent = {
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: this.providerName,
|
|
||||||
usage: { inputTokens: 1, outputTokens: 1 },
|
|
||||||
};
|
|
||||||
await this.appendHistoryEvent(completed);
|
|
||||||
this.notifySubscribers(completed);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveToolPermission(tool: {
|
private async resolveToolPermission(tool: {
|
||||||
name: string;
|
name: string;
|
||||||
input?: Record<string, unknown>;
|
input?: Record<string, unknown>;
|
||||||
@@ -781,16 +729,6 @@ class FakeAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (textPrompt.toLowerCase().includes("emit an actionable plan")) {
|
|
||||||
await this.emitActionablePlanTurn(textPrompt);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textPrompt.toLowerCase().includes("emit a plan file")) {
|
|
||||||
await this.emitPlanFileTurn();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tool = buildToolCallForPrompt(this.providerName, textPrompt);
|
const tool = buildToolCallForPrompt(this.providerName, textPrompt);
|
||||||
if (tool) {
|
if (tool) {
|
||||||
const returnedEarly = await this.emitToolCallTurn(tool, textPrompt);
|
const returnedEarly = await this.emitToolCallTurn(tool, textPrompt);
|
||||||
@@ -896,20 +834,6 @@ class FakeAgentSession implements AgentSession {
|
|||||||
this.permissionGate = null;
|
this.permissionGate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async respondToPlan(
|
|
||||||
planId: string,
|
|
||||||
response: AgentPlanResponse,
|
|
||||||
): Promise<AgentPlanResult | void> {
|
|
||||||
const pending = this.pendingPlans.get(planId);
|
|
||||||
if (!pending) {
|
|
||||||
throw new Error(`No pending fake plan with id '${planId}'`);
|
|
||||||
}
|
|
||||||
this.pendingPlans.delete(planId);
|
|
||||||
if (response.actionId === "implement") {
|
|
||||||
return { followUpPrompt: `Implement fake plan:\n${pending.text}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describePersistence(): AgentPersistenceHandle | null {
|
describePersistence(): AgentPersistenceHandle | null {
|
||||||
return buildPersistence(
|
return buildPersistence(
|
||||||
this.providerName,
|
this.providerName,
|
||||||
|
|||||||
@@ -1043,8 +1043,6 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
providersSnapshot: true,
|
providersSnapshot: true,
|
||||||
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
|
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
|
||||||
checkoutGithubSetAutoMerge: true,
|
checkoutGithubSetAutoMerge: true,
|
||||||
// COMPAT(firstClassPlans): added in v0.1.82, remove gate after 2026-11-28.
|
|
||||||
firstClassPlans: true,
|
|
||||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||||
daemonStatusRpc: true,
|
daemonStatusRpc: true,
|
||||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||||
|
|||||||
@@ -73,18 +73,6 @@ const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({
|
|||||||
capabilities: LegacyAgentCapabilityFlagsSchema,
|
capabilities: LegacyAgentCapabilityFlagsSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const LegacyPlanToolCallSchema = z.object({
|
|
||||||
type: z.literal("tool_call"),
|
|
||||||
callId: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
status: z.enum(["running", "completed", "failed", "canceled"]),
|
|
||||||
error: z.unknown().nullable(),
|
|
||||||
detail: z.object({
|
|
||||||
type: z.literal("plan"),
|
|
||||||
text: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface SessionInternals {
|
interface SessionInternals {
|
||||||
handleFetchAgentTimelineRequest: (
|
handleFetchAgentTimelineRequest: (
|
||||||
message: Extract<
|
message: Extract<
|
||||||
@@ -244,16 +232,6 @@ function createSessionForWireCompatTest(options?: {
|
|||||||
timestamp: "2026-05-02T00:00:00.200Z",
|
timestamp: "2026-05-02T00:00:00.200Z",
|
||||||
item: { type: "assistant_message", text: "done" },
|
item: { type: "assistant_message", text: "done" },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
seq: 4,
|
|
||||||
timestamp: "2026-05-02T00:00:00.300Z",
|
|
||||||
item: {
|
|
||||||
type: "plan",
|
|
||||||
planId: "plan-1",
|
|
||||||
text: "# Plan\n\n- Do the thing",
|
|
||||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
@@ -389,38 +367,6 @@ describe("wire compatibility", () => {
|
|||||||
expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge");
|
expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("downgrades plan timeline items for clients that do not declare the capability", async () => {
|
|
||||||
const response = await emitTimelineResponse();
|
|
||||||
|
|
||||||
const entry = response.payload.entries.find((item) => item.seqStart === 4);
|
|
||||||
expect(entry?.item).toEqual({
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "plan-1",
|
|
||||||
name: "Plan",
|
|
||||||
status: "completed",
|
|
||||||
error: null,
|
|
||||||
detail: {
|
|
||||||
type: "plan",
|
|
||||||
text: "# Plan\n\n- Do the thing",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(() => LegacyPlanToolCallSchema.parse(entry?.item)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves plan timeline items for clients that declare the capability", async () => {
|
|
||||||
const response = await emitTimelineResponse({
|
|
||||||
[CLIENT_CAPS.firstClassPlans]: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const entry = response.payload.entries.find((item) => item.seqStart === 4);
|
|
||||||
expect(entry?.item).toEqual({
|
|
||||||
type: "plan",
|
|
||||||
planId: "plan-1",
|
|
||||||
text: "# Plan\n\n- Do the thing",
|
|
||||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
|
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
|
||||||
const parsed = LegacySubAgentToolCallSchema.parse({
|
const parsed = LegacySubAgentToolCallSchema.parse({
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
|
|||||||
Reference in New Issue
Block a user