mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Show agent messages and completion notifications with sender identity (#6373)
* feat(agents): show agent messages and completion notifications with sender identity * fix(agents): preserve human envelope examples and attachment context * fix(agents): separate message provenance from timeline presentation
This commit is contained in:
+19
-5
@@ -223,7 +223,7 @@ submission. Overlapping sends settle independently rather than collapsing to one
|
||||
message.
|
||||
|
||||
Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted
|
||||
prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
|
||||
human prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
|
||||
same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted
|
||||
row is recorded before handler output. The app tracks submission transactions only for hosts with this
|
||||
capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC
|
||||
@@ -238,11 +238,25 @@ foreground control ownership remains a separate daemon concern. Cancellation req
|
||||
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
||||
remain a separate pre-turn registry and retire on canonical acknowledgement.
|
||||
|
||||
Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
|
||||
the Paseo timeline, so one visible response can span several canonical turns without a user message
|
||||
between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
|
||||
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
|
||||
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
|
||||
prompts, including schedule injections, remain hidden. One visible response can span several canonical
|
||||
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
|
||||
fork positions retain the canonical `turnId` boundaries.
|
||||
|
||||
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
|
||||
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
|
||||
also the projected call ID, so a provider echo cannot create a second row. Sender IDs are opaque and
|
||||
do not imply local ownership or authorization. Capture the sender title at delivery: archived or
|
||||
removed senders must remain understandable without a live directory lookup.
|
||||
|
||||
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
|
||||
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
|
||||
must not construct UI labels or prepend sender labels to the semantic message body. The existing
|
||||
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
|
||||
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
|
||||
XML. Stream reduction and disk-cache serialization preserve the payload without rendering it.
|
||||
|
||||
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
|
||||
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
|
||||
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
|
||||
@@ -250,7 +264,7 @@ second running state. Disconnect preserves the last replicated turn until cache
|
||||
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
|
||||
liveness, never from submission records or whichever timeline rows happen to be mounted.
|
||||
|
||||
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
||||
For human prompts, the daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
||||
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
|
||||
the wire. A correlated provider echo records the provider's native identity internally without
|
||||
dispatching another timeline event. Rewind resolves the wire identity to that provider identity at
|
||||
|
||||
@@ -792,6 +792,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
detail={data.detail}
|
||||
cwd={context.cwd}
|
||||
metadata={data.metadata}
|
||||
agentMessage={data.agentMessage}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import { ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO } from "@/utils/assistant-image-metadata";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { TaskListRow } from "@/components/task-list-row";
|
||||
@@ -3022,6 +3023,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
}
|
||||
|
||||
interface ToolCallProps {
|
||||
agentMessage?: AgentMessage;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
@@ -3041,6 +3043,7 @@ interface ToolCallProps {
|
||||
}
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
agentMessage,
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
@@ -3081,6 +3084,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
const presentation = useMemo(
|
||||
() =>
|
||||
buildToolCallPresentation({
|
||||
agentMessage,
|
||||
toolName,
|
||||
status,
|
||||
error: error ?? null,
|
||||
@@ -3089,7 +3093,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
cwd,
|
||||
resolveIcon: resolveToolCallIcon,
|
||||
}),
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd],
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
|
||||
);
|
||||
const handleOpenFile = useMemo(() => {
|
||||
const openFilePath = presentation.openFilePath;
|
||||
@@ -3105,7 +3109,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
toolName,
|
||||
displayName: presentation.displayName,
|
||||
summary: presentation.summary,
|
||||
detail: effectiveDetail,
|
||||
detail: presentation.detail,
|
||||
errorText: presentation.errorText,
|
||||
icon: presentation.icon,
|
||||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||||
@@ -3117,12 +3121,12 @@ export const ToolCall = memo(function ToolCall({
|
||||
shouldRenderInline,
|
||||
openToolCall,
|
||||
toolName,
|
||||
presentation.detail,
|
||||
presentation.displayName,
|
||||
presentation.summary,
|
||||
presentation.errorText,
|
||||
presentation.icon,
|
||||
presentation.isLoadingDetails,
|
||||
effectiveDetail,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3158,7 +3162,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
toolName={toolName}
|
||||
detail={effectiveDetail}
|
||||
detail={presentation.detail}
|
||||
errorText={presentation.errorText}
|
||||
maxHeight={maxDetailHeight}
|
||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||
@@ -3167,7 +3171,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
toolName,
|
||||
effectiveDetail,
|
||||
presentation.detail,
|
||||
presentation.errorText,
|
||||
presentation.isLoadingDetails,
|
||||
maxDetailHeight,
|
||||
|
||||
@@ -859,3 +859,36 @@ describe("ReplicaCache", () => {
|
||||
expect(storage.cleanups).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("retains semantic agent messages across disk-cache reconstruction", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const value = timeline();
|
||||
value.items = [
|
||||
{
|
||||
kind: "tool_call",
|
||||
id: "delivery",
|
||||
timestamp: new Date(1),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 12 },
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "codex",
|
||||
callId: "delivery",
|
||||
name: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: "Done" },
|
||||
agentMessage: {
|
||||
event: "finished",
|
||||
sender: { id: "remote::worker", title: "Reviewer" },
|
||||
text: "Done",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", value);
|
||||
await writer.flush();
|
||||
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(value);
|
||||
});
|
||||
|
||||
@@ -421,6 +421,7 @@ function serializeAgentToolCall(data: AgentToolCallData): StoredToolCall {
|
||||
name: data.name,
|
||||
detail: data.detail,
|
||||
...(data.metadata ? { metadata: data.metadata } : {}),
|
||||
...(data.agentMessage ? { agentMessage: data.agentMessage } : {}),
|
||||
};
|
||||
switch (data.status) {
|
||||
case "running":
|
||||
@@ -584,6 +585,7 @@ function deserializeBuiltinTimelineItem(
|
||||
error: tool.error,
|
||||
detail: tool.detail,
|
||||
...(tool.metadata ? { metadata: tool.metadata } : {}),
|
||||
...(tool.agentMessage ? { agentMessage: tool.agentMessage } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -92,3 +92,42 @@ describe("tool-call presentation", () => {
|
||||
expect(presentation.icon).toBe(fakeIcons.brain);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["message", "Message from QA messenger"],
|
||||
["finished", "QA messenger finished"],
|
||||
["errored", "QA messenger errored"],
|
||||
["permission-required", "QA messenger needs permission"],
|
||||
["closed", "QA messenger closed"],
|
||||
] as const)("presents %s from structured provenance without a second label", (event, heading) => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "internal_name_should_not_be_visible",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: "legacy summary", text: "legacy body" },
|
||||
agentMessage: {
|
||||
event,
|
||||
sender: { id: "server::sender", title: "QA messenger" },
|
||||
text: "The delivered message.",
|
||||
},
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
expect(presentation.displayName).toBe(heading);
|
||||
expect(presentation.summary).toBeUndefined();
|
||||
expect(presentation.detail).toEqual({
|
||||
type: "plain_text",
|
||||
icon: "bot",
|
||||
text: "From agent: server::sender\n\nThe delivered message.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an opaque sender ID when no title is available", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
agentMessage: { event: "message", sender: { id: "remote::worker" }, text: "Hello" },
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
expect(presentation.displayName).toBe("Message from remote::worker");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import { buildAgentMessageDisplay } from "@getpaseo/protocol/agent-message-display";
|
||||
import type { PlanOutcome } from "@/components/plan-card";
|
||||
import type { ComponentType } from "react";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -13,6 +15,7 @@ type ToolCallStatus = "executing" | "running" | "completed" | "failed" | "cancel
|
||||
export type ToolCallPresentationIcon = ComponentType<{ size?: number; color?: string }>;
|
||||
|
||||
interface BuildToolCallPresentationInput {
|
||||
agentMessage?: AgentMessage;
|
||||
toolName: string;
|
||||
status: ToolCallStatus;
|
||||
error: unknown;
|
||||
@@ -23,6 +26,7 @@ interface BuildToolCallPresentationInput {
|
||||
}
|
||||
|
||||
export interface ToolCallPresentation {
|
||||
detail: ToolCallDetail | undefined;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
errorText?: string;
|
||||
@@ -51,9 +55,13 @@ function displayDetail(detail: ToolCallDetail | undefined): ToolCallDetail {
|
||||
export function buildToolCallPresentation(
|
||||
input: BuildToolCallPresentationInput,
|
||||
): ToolCallPresentation {
|
||||
const detailForDisplay = displayDetail(input.detail);
|
||||
const detail = input.agentMessage
|
||||
? buildAgentMessageDisplay(input.agentMessage).detail
|
||||
: input.detail;
|
||||
const detailForDisplay = displayDetail(detail);
|
||||
const displayModel = buildToolCallDisplayModel({
|
||||
name: input.toolName,
|
||||
agentMessage: input.agentMessage,
|
||||
status: displayStatus(input.status),
|
||||
error: input.error ?? null,
|
||||
detail: detailForDisplay,
|
||||
@@ -61,21 +69,22 @@ export function buildToolCallPresentation(
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail: input.detail,
|
||||
detail,
|
||||
status: input.status,
|
||||
error: input.error,
|
||||
});
|
||||
const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(input.detail);
|
||||
const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(detail);
|
||||
|
||||
return {
|
||||
detail,
|
||||
displayName: displayModel.displayName,
|
||||
summary: displayModel.summary,
|
||||
errorText: displayModel.errorText,
|
||||
icon: input.resolveIcon(input.toolName, input.detail),
|
||||
icon: input.resolveIcon(input.toolName, detail),
|
||||
isLoadingDetails,
|
||||
hasDetails,
|
||||
canOpenDetails: hasDetails || isLoadingDetails,
|
||||
openFilePath: extractToolCallFilePath(input.detail),
|
||||
openFilePath: extractToolCallFilePath(detail),
|
||||
isPlan: input.detail?.type === "plan",
|
||||
planOutcome: input.detail?.type === "plan" ? resolvePlanOutcome(input) : undefined,
|
||||
};
|
||||
|
||||
@@ -2305,3 +2305,28 @@ describe("notification timeline items", () => {
|
||||
expect(new Set(state.map((item) => item.id)).size).toBe(state.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("retains agent-message provenance through live delivery and repeated history", () => {
|
||||
const agentMessage = {
|
||||
event: "finished" as const,
|
||||
sender: { id: "remote::worker", title: "Reviewer" },
|
||||
text: "Done",
|
||||
};
|
||||
const event = {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "delivery",
|
||||
name: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: "Done" },
|
||||
agentMessage,
|
||||
},
|
||||
} satisfies AgentStreamEventPayload;
|
||||
const live = reduceStreamUpdate([], event, new Date(1));
|
||||
const replay = reduceStreamUpdate(live, event, new Date(2));
|
||||
expect(replay).toHaveLength(1);
|
||||
expect(replay[0]).toMatchObject({ payload: { data: { agentMessage } } });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type {
|
||||
AgentProvider,
|
||||
AgentTimelineItem,
|
||||
@@ -745,6 +746,7 @@ interface OrchestratorToolCallData {
|
||||
}
|
||||
|
||||
export interface AgentToolCallData {
|
||||
agentMessage?: AgentMessage;
|
||||
provider: AgentProvider;
|
||||
callId: string;
|
||||
name: string;
|
||||
@@ -1446,6 +1448,7 @@ function reduceTimelineToolCall(
|
||||
error: item.error,
|
||||
detail: item.detail,
|
||||
metadata: item.metadata,
|
||||
...(item.agentMessage ? { agentMessage: item.agentMessage } : {}),
|
||||
},
|
||||
timestamp,
|
||||
timelineCursor,
|
||||
|
||||
@@ -192,7 +192,10 @@ export async function runSendCommand(
|
||||
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
|
||||
|
||||
// Send the message
|
||||
await client.sendAgentMessage(agentIdArg, promptInput, { images });
|
||||
await client.sendAgentMessage(agentIdArg, promptInput, {
|
||||
images,
|
||||
sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined,
|
||||
});
|
||||
|
||||
// If --no-wait, return immediately
|
||||
if (options.wait === false) {
|
||||
|
||||
@@ -7325,3 +7325,49 @@ test("usage request timeout detaches its update listener", async () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"agent provenance requires host support=%s while human sends remain compatible",
|
||||
async (supported) => {
|
||||
const transport = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "provenance",
|
||||
transportFactory: () => transport.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
transport.triggerOpen({ features: { agentMessageProvenance: supported } });
|
||||
await connecting;
|
||||
if (!supported) {
|
||||
await expect(
|
||||
client.sendAgentMessage("recipient", "review", { sourceAgentId: "server::sender" }),
|
||||
).rejects.toThrow("Update the Paseo host");
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
}
|
||||
const send = client.sendAgentMessage("recipient", "review", {
|
||||
messageId: "delivery",
|
||||
...(supported ? { sourceAgentId: "server::sender" } : {}),
|
||||
});
|
||||
const request = parseSentFrame(transport.sent[0]);
|
||||
expect(request).toMatchObject({
|
||||
type: "send_agent_message_request",
|
||||
text: "review",
|
||||
messageId: "delivery",
|
||||
});
|
||||
expect(request.sourceAgentId).toBe(supported ? "server::sender" : undefined);
|
||||
transport.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
agentId: "recipient",
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await send;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -424,6 +424,7 @@ export interface DaemonClientTrace {
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
sourceAgentId?: string;
|
||||
messageId?: string;
|
||||
/** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */
|
||||
activeTurnBehavior?: ActiveTurnBehavior;
|
||||
@@ -3447,6 +3448,12 @@ export class DaemonClient {
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
if (
|
||||
options?.sourceAgentId &&
|
||||
this.lastServerInfoMessage?.features?.agentMessageProvenance !== true
|
||||
) {
|
||||
throw new Error("Update the Paseo host to send messages with agent provenance.");
|
||||
}
|
||||
const requestId = this.createRequestId();
|
||||
const messageId = options?.messageId ?? crypto.randomUUID();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -3454,6 +3461,7 @@ export class DaemonClient {
|
||||
requestId,
|
||||
agentId,
|
||||
text,
|
||||
sourceAgentId: options?.sourceAgentId,
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}),
|
||||
...(options?.images ? { images: options.images } : {}),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AgentMessage } from "./agent-message.js";
|
||||
import type { ToolCallDetail } from "./agent-types.js";
|
||||
|
||||
/** Presentation shared by clients; never used to encode provider prompts. */
|
||||
export function buildAgentMessageDisplay(message: AgentMessage): {
|
||||
displayName: string;
|
||||
detail: ToolCallDetail;
|
||||
} {
|
||||
const sender = message.sender.title ?? message.sender.id;
|
||||
const headings: Record<AgentMessage["event"], string> = {
|
||||
message: `Message from ${sender}`,
|
||||
finished: `${sender} finished`,
|
||||
errored: `${sender} errored`,
|
||||
"permission-required": `${sender} needs permission`,
|
||||
closed: `${sender} closed`,
|
||||
};
|
||||
return {
|
||||
displayName: headings[message.event],
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "bot",
|
||||
text: `From agent: ${message.sender.id}\n\n${message.text}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Semantic provenance, independent of provider envelopes and timeline presentation. */
|
||||
export const AgentMessageSchema = z.object({
|
||||
event: z.enum(["message", "finished", "errored", "permission-required", "closed"]),
|
||||
sender: z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1).optional(),
|
||||
}),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export type AgentMessage = z.infer<typeof AgentMessageSchema>;
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentMessage } from "./agent-message.js";
|
||||
import type { AgentAttachment } from "./messages.js";
|
||||
|
||||
export type AgentProvider = string;
|
||||
@@ -311,6 +312,7 @@ export type ToolCallDetail =
|
||||
};
|
||||
|
||||
interface ToolCallBase {
|
||||
agentMessage?: AgentMessage;
|
||||
[key: string]: unknown;
|
||||
type: "tool_call";
|
||||
callId: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
AgentTimelineItemPayloadSchema,
|
||||
FileExplorerRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
parseServerInfoStatusPayload,
|
||||
@@ -520,3 +521,30 @@ describe("viewed timeline subscription messages", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("agent messages are an optional semantic payload on existing tool rows", () => {
|
||||
const legacy = {
|
||||
type: "tool_call",
|
||||
callId: "delivery",
|
||||
name: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: "Done" },
|
||||
};
|
||||
const agentMessage = {
|
||||
event: "finished",
|
||||
sender: { id: "server::worker", title: "Reviewer" },
|
||||
text: "Done",
|
||||
};
|
||||
expect(AgentTimelineItemPayloadSchema.parse(legacy)).toEqual(legacy);
|
||||
expect(AgentTimelineItemPayloadSchema.parse({ ...legacy, agentMessage })).toEqual({
|
||||
...legacy,
|
||||
agentMessage,
|
||||
});
|
||||
expect(
|
||||
AgentTimelineItemPayloadSchema.safeParse({
|
||||
...legacy,
|
||||
agentMessage: { ...agentMessage, sender: { id: 42 } },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentMessageSchema } from "./agent-message.js";
|
||||
import { PluginRegistryIdentitySchema } from "./plugin-registry.js";
|
||||
import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js";
|
||||
export {
|
||||
@@ -625,6 +626,7 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, unknown> = z.discri
|
||||
);
|
||||
|
||||
const ToolCallBasePayloadSchema = z.object({
|
||||
agentMessage: AgentMessageSchema.optional(),
|
||||
type: z.literal("tool_call"),
|
||||
callId: z.string(),
|
||||
name: z.string(),
|
||||
@@ -1354,6 +1356,8 @@ export const SendAgentMessageRequestSchema = z.object({
|
||||
/** Accepts full ID, unique prefix, or exact full title (server resolves). */
|
||||
agentId: z.string(),
|
||||
text: z.string(),
|
||||
/** Opaque sender identity for agent-originated prompts. */
|
||||
sourceAgentId: z.string().min(1).optional(),
|
||||
messageId: z.string().optional(), // Client-provided ID for deduplication
|
||||
activeTurnBehavior: ActiveTurnBehaviorSchema.optional(),
|
||||
images: z.array(ImageAttachmentSchema).optional(),
|
||||
@@ -3695,6 +3699,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
ownedSubscriptions: z.boolean().optional(),
|
||||
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30.
|
||||
canonicalSubmittedPrompts: z.boolean().optional(),
|
||||
agentMessageProvenance: z.boolean().optional(),
|
||||
// COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31.
|
||||
agentTurnIdentity: z.boolean().optional(),
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { buildAgentMessageDisplay } from "./agent-message-display.js";
|
||||
import type { ToolCallTimelineItem } from "./agent-types.js";
|
||||
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
|
||||
import { stripCwdPrefix } from "./path-utils.js";
|
||||
|
||||
export type ToolCallDisplayInput = Pick<
|
||||
ToolCallTimelineItem,
|
||||
"name" | "status" | "error" | "metadata" | "detail"
|
||||
"name" | "status" | "error" | "metadata" | "detail" | "agentMessage"
|
||||
> & {
|
||||
cwd?: string;
|
||||
};
|
||||
@@ -151,6 +152,9 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay
|
||||
}
|
||||
|
||||
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
|
||||
if (input.agentMessage) {
|
||||
return { displayName: buildAgentMessageDisplay(input.agentMessage).displayName };
|
||||
}
|
||||
const canonicalDisplay = buildCanonicalDetailDisplay(input);
|
||||
const unknownDetailOverride = buildUnknownDetailOverride(input);
|
||||
const displayName =
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
prepareAgentMessage,
|
||||
formatAgentMessage,
|
||||
formatSystemNotificationPrompt,
|
||||
} from "./agent-messages/index.js";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
@@ -18,7 +23,7 @@ import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js";
|
||||
import { startAgentRun } from "./agent-prompt.js";
|
||||
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
@@ -11467,3 +11472,173 @@ test("usage session is a pure read of the live adapter and disappears on close",
|
||||
}
|
||||
expect(manager.usageSession(agent.id)).toBeNull();
|
||||
});
|
||||
|
||||
test.each(["live", "history"])(
|
||||
"agent messages become notification tools during %s ingestion",
|
||||
async (path) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-message-provenance-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const item: AgentTimelineItem = {
|
||||
type: "user_message",
|
||||
text: '<paseo-system version="1" kind="agent-message" source-agent-id="remote::sender" message-id="delivery-1">\nReview <changes> & report back.\n</paseo-system>',
|
||||
messageId: "provider-message",
|
||||
};
|
||||
const codex = fakeCodexEmitting(
|
||||
path === "live" ? { turnItems: [item] } : { historyItems: [item] },
|
||||
);
|
||||
const manager = new AgentManager({ clients: { codex }, registry: storage, logger });
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
if (path === "live") await manager.runAgent(agent.id, { text: "start" });
|
||||
else await manager.hydrateTimelineFromProvider(agent.id, { force: true });
|
||||
expect(manager.getTimeline(agent.id)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "tool_call",
|
||||
callId: "paseo-agent-message:delivery-1",
|
||||
agentMessage: {
|
||||
event: "message",
|
||||
sender: { id: "remote::sender" },
|
||||
text: "Review <changes> & report back.",
|
||||
},
|
||||
name: "agent_message",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "bot",
|
||||
text: "Review <changes> & report back.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(manager.getTimeline(agent.id).filter((entry) => entry.type === "user_message")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([false, true])(
|
||||
"accepted agent prompt appears once with provider echo=%s",
|
||||
async (echo) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-message-acceptance-"));
|
||||
const delivery = prepareAgentMessage(
|
||||
"Please review",
|
||||
{ kind: "agent-message", agentId: "remote::sender" },
|
||||
"accepted-delivery",
|
||||
);
|
||||
const codex = fakeCodexEmitting({
|
||||
turnItems: echo
|
||||
? [
|
||||
{
|
||||
type: "user_message",
|
||||
text: String(delivery.prompt),
|
||||
clientMessageId: delivery.messageId,
|
||||
messageId: "native-echo",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex }, logger });
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
for await (const _event of manager.streamAgent(agent.id, delivery.prompt, {
|
||||
clientMessageId: delivery.messageId,
|
||||
})) {
|
||||
}
|
||||
expect(manager.getTimeline(agent.id).filter((item) => item.type === "tool_call")).toEqual([
|
||||
expect.objectContaining({
|
||||
callId: "paseo-agent-message:accepted-delivery",
|
||||
status: "completed",
|
||||
}),
|
||||
]);
|
||||
expect(manager.getTimeline(agent.id).filter((item) => item.type === "user_message")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull();
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("a rejected agent prompt never becomes a delivered notification", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-message-rejected-"));
|
||||
const delivery = prepareAgentMessage(
|
||||
"Please review",
|
||||
{ kind: "agent-message", agentId: "sender" },
|
||||
"rejected",
|
||||
);
|
||||
class RejectingSession extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
throw new Error("provider rejected delivery");
|
||||
}
|
||||
}
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig) {
|
||||
return new RejectingSession(config);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger });
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
const run = manager.streamAgent(agent.id, delivery.prompt, {
|
||||
clientMessageId: delivery.messageId,
|
||||
});
|
||||
await expect(run.next()).rejects.toThrow("provider rejected delivery");
|
||||
expect(manager.getTimeline(agent.id).filter((item) => item.type === "tool_call")).toEqual([]);
|
||||
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull();
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"human envelope examples remain human after echo with correlation=%s",
|
||||
async (correlated) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "human-envelope-example-"));
|
||||
const example = formatAgentMessage({
|
||||
id: "example",
|
||||
source: { kind: "agent-message", agentId: "sender" },
|
||||
text: "hello",
|
||||
});
|
||||
const delivery = prepareAgentMessage(example, undefined, "human-submission");
|
||||
const codex = fakeCodexEmitting({
|
||||
turnItems: [
|
||||
{
|
||||
type: "user_message",
|
||||
text: String(delivery.prompt),
|
||||
messageId: "provider-echo",
|
||||
...(correlated ? { clientMessageId: "human-submission" } : {}),
|
||||
},
|
||||
],
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex }, logger });
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
for await (const _event of manager.streamAgent(agent.id, delivery.prompt, {
|
||||
clientMessageId: delivery.messageId,
|
||||
})) {
|
||||
}
|
||||
expect(manager.getTimeline(agent.id)).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: example,
|
||||
clientMessageId: "human-submission",
|
||||
messageId: "human-submission",
|
||||
},
|
||||
]);
|
||||
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeInstanceOf(Date);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -78,7 +78,7 @@ import {
|
||||
type PendingForegroundRun,
|
||||
} from "./agent-run-state.js";
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
||||
import { projectAgentMessage } from "./agent-messages/index.js";
|
||||
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||
import { resolveCreateAgentTitles } from "./create-agent-title.js";
|
||||
@@ -652,13 +652,12 @@ function buildExplicitTimelineSeedForRegister(
|
||||
function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] {
|
||||
const rows: AgentTimelineRow[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.item.type === "user_message" && isSystemInjectedEnvelope(entry.item.text)) {
|
||||
continue;
|
||||
}
|
||||
const item = projectAgentMessage(entry.item);
|
||||
if (!item) continue;
|
||||
rows.push({
|
||||
seq: rows.length + 1,
|
||||
timestamp: entry.timestamp ?? new Date().toISOString(),
|
||||
item: limitAgentTimelineItemContent(entry.item),
|
||||
item: limitAgentTimelineItemContent(item),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
@@ -3511,7 +3510,7 @@ export class AgentManager {
|
||||
// Legacy/imported chats need their existing history before startup rows.
|
||||
await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory);
|
||||
} else {
|
||||
for (const entry of session.initialTimeline) {
|
||||
for (const entry of buildImportedTimelineRows(session.initialTimeline)) {
|
||||
this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp });
|
||||
}
|
||||
}
|
||||
@@ -4018,10 +4017,9 @@ export class AgentManager {
|
||||
for await (const rawEvent of agent.session.streamHistory()) {
|
||||
const event = limitAgentStreamEventContent(rawEvent);
|
||||
if (event.type === "timeline") {
|
||||
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
|
||||
continue;
|
||||
}
|
||||
historyEvents.push(event);
|
||||
const item = projectAgentMessage(event.item);
|
||||
if (!item) continue;
|
||||
historyEvents.push({ ...event, item });
|
||||
} else if (event.type === "provider_subagent") {
|
||||
providerSubagentEvents.push(event);
|
||||
}
|
||||
@@ -4086,10 +4084,9 @@ export class AgentManager {
|
||||
if (event.type !== "timeline") {
|
||||
continue;
|
||||
}
|
||||
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
|
||||
continue;
|
||||
}
|
||||
historyEvents.push(event);
|
||||
const item = projectAgentMessage(event.item);
|
||||
if (!item) continue;
|
||||
historyEvents.push({ ...event, item });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history");
|
||||
@@ -4421,12 +4418,14 @@ export class AgentManager {
|
||||
}): Promise<void> {
|
||||
const { agent, event, options, flags } = params;
|
||||
|
||||
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
|
||||
const item = projectAgentMessage(event.item);
|
||||
if (!item) {
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
return;
|
||||
}
|
||||
|
||||
event.item = item;
|
||||
if (
|
||||
event.item.type === "user_message" &&
|
||||
event.item.clientMessageId &&
|
||||
@@ -4699,17 +4698,28 @@ export class AgentManager {
|
||||
clientMessageId: string,
|
||||
options?: { messageId?: string; providerMessageId?: string; turnId?: string },
|
||||
): void {
|
||||
if (this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId)) {
|
||||
return;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
agent.lastUserMessageAt = new Date();
|
||||
const item: AgentTimelineItem = {
|
||||
const item = projectAgentMessage({
|
||||
type: "user_message",
|
||||
text: submittedPromptText(prompt),
|
||||
clientMessageId,
|
||||
...(options?.messageId ? { messageId: options.messageId } : {}),
|
||||
};
|
||||
});
|
||||
if (item) this.recordSubmittedPromptItem(agent, item, options);
|
||||
}
|
||||
|
||||
private recordSubmittedPromptItem(
|
||||
agent: ActiveManagedAgent,
|
||||
item: AgentTimelineItem,
|
||||
options?: { providerMessageId?: string; turnId?: string },
|
||||
): void {
|
||||
if (
|
||||
item.type === "user_message" &&
|
||||
item.clientMessageId &&
|
||||
this.timelineStore.getSubmittedUserMessage(agent.id, item.clientMessageId)
|
||||
)
|
||||
return;
|
||||
this.touchUpdatedAt(agent);
|
||||
if (item.type === "user_message") agent.lastUserMessageAt = new Date();
|
||||
this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, options?.turnId, options);
|
||||
}
|
||||
|
||||
@@ -4722,11 +4732,14 @@ export class AgentManager {
|
||||
if (!clientMessageId) return null;
|
||||
let existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
|
||||
if (!existing) {
|
||||
this.recordSubmittedPrompt(agent, item.text, clientMessageId, {
|
||||
messageId: clientMessageId,
|
||||
...(messageId ? { providerMessageId: messageId } : {}),
|
||||
...(turnId ? { turnId } : {}),
|
||||
});
|
||||
this.recordSubmittedPromptItem(
|
||||
agent,
|
||||
{ ...item, messageId: clientMessageId },
|
||||
{
|
||||
...(messageId ? { providerMessageId: messageId } : {}),
|
||||
...(turnId ? { turnId } : {}),
|
||||
},
|
||||
);
|
||||
existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
|
||||
}
|
||||
if (!existing || existing.item.type !== "user_message") return null;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
const agentIdSchema = z.string().min(1);
|
||||
const sourceSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("agent-message"),
|
||||
agentId: agentIdSchema,
|
||||
title: z.string().min(1).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("agent-notification"),
|
||||
agentId: agentIdSchema,
|
||||
title: z.string().min(1).optional(),
|
||||
event: z.enum(["finished", "errored", "permission-required", "closed"]),
|
||||
}),
|
||||
]);
|
||||
export type AgentPromptSource = z.infer<typeof sourceSchema>;
|
||||
|
||||
interface AgentMessage {
|
||||
id: string;
|
||||
source: AgentPromptSource | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => {
|
||||
switch (character) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unescapeXml(value: string): string | null {
|
||||
if (/[<>]|&(?!amp;|lt;|gt;|quot;|apos;)/.test(value)) return null;
|
||||
return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity: string) => {
|
||||
switch (entity) {
|
||||
case "amp":
|
||||
return "&";
|
||||
case "lt":
|
||||
return "<";
|
||||
case "gt":
|
||||
return ">";
|
||||
case "quot":
|
||||
return '"';
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function formatAgentMessage(message: AgentMessage): string {
|
||||
const { source } = message;
|
||||
const attributes = source
|
||||
? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"`
|
||||
: 'kind="user-message"';
|
||||
const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : "";
|
||||
const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : "";
|
||||
return `<paseo-system version="1" ${attributes} message-id="${escapeXml(message.id)}"${title}${event}>\n${escapeXml(message.text)}\n</paseo-system>`;
|
||||
}
|
||||
|
||||
export function parseAgentMessage(text: string): AgentMessage | null {
|
||||
const envelope = /^<paseo-system((?:\s+[a-z-]+="[^"<>]*")+)>\n([\s\S]*)\n<\/paseo-system>$/.exec(
|
||||
text,
|
||||
);
|
||||
if (!envelope) return null;
|
||||
const attributes = new Map<string, string>();
|
||||
for (const match of envelope[1].matchAll(/\s+([a-z-]+)="([^"<>]*)"/g)) {
|
||||
const value = unescapeXml(match[2]);
|
||||
if (value === null || attributes.has(match[1])) return null;
|
||||
attributes.set(match[1], value);
|
||||
}
|
||||
if (attributes.get("version") !== "1") return null;
|
||||
const id = attributes.get("message-id");
|
||||
const body = unescapeXml(envelope[2]);
|
||||
if (!id || body === null) return null;
|
||||
if (attributes.get("kind") === "user-message") {
|
||||
if (attributes.has("source-agent-id") || attributes.has("event")) return null;
|
||||
return { id, source: null, text: body };
|
||||
}
|
||||
const source = sourceSchema.safeParse({
|
||||
kind: attributes.get("kind"),
|
||||
agentId: attributes.get("source-agent-id"),
|
||||
event: attributes.get("event"),
|
||||
title: attributes.get("source-agent-title"),
|
||||
});
|
||||
if (!source.success) return null;
|
||||
return { id, source: source.data, text: body };
|
||||
}
|
||||
|
||||
function renderMessageText(block: AgentPromptContentBlock): string[] {
|
||||
if (block.type === "image") return [];
|
||||
if (block.type === "text") return [block.text];
|
||||
return [renderPromptAttachmentAsText(block)];
|
||||
}
|
||||
|
||||
export function prepareAgentMessage(
|
||||
prompt: AgentPromptInput,
|
||||
source?: AgentPromptSource,
|
||||
id?: string,
|
||||
): { prompt: AgentPromptInput; messageId?: string } {
|
||||
if (!source) {
|
||||
const submittedText =
|
||||
typeof prompt === "string"
|
||||
? prompt
|
||||
: prompt
|
||||
.flatMap((block) =>
|
||||
block.type === "text" && !("mimeType" in block) ? [block.text] : [],
|
||||
)
|
||||
.join("\n")
|
||||
.trim();
|
||||
// Quote reserved envelopes only when they arrived through the human send path.
|
||||
// This provenance must survive provider history, not only the accepted local row.
|
||||
if (!parseAgentMessage(submittedText) && !isSystemInjectedEnvelope(submittedText)) {
|
||||
return { prompt, messageId: id };
|
||||
}
|
||||
}
|
||||
const messageId = id ?? randomUUID();
|
||||
const text = typeof prompt === "string" ? prompt : prompt.flatMap(renderMessageText).join("\n\n");
|
||||
// Render textual attachments before wrapping, so provider replay still contains one
|
||||
// complete envelope instead of an envelope followed by provider-rendered context.
|
||||
const envelope = formatAgentMessage({ id: messageId, source: source ?? null, text });
|
||||
return {
|
||||
messageId,
|
||||
prompt:
|
||||
typeof prompt === "string"
|
||||
? envelope
|
||||
: [{ type: "text", text: envelope }, ...prompt.filter((block) => block.type === "image")],
|
||||
};
|
||||
}
|
||||
|
||||
/** The same projection owns provider echoes, acceptance, import, and replay. */
|
||||
export function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null {
|
||||
if (item.type !== "user_message") return item;
|
||||
const message = parseAgentMessage(item.text);
|
||||
if (!message) {
|
||||
// COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once
|
||||
// legacy notification history is retired. Schedules still use this envelope.
|
||||
return isSystemInjectedEnvelope(item.text) ? null : item;
|
||||
}
|
||||
const { source } = message;
|
||||
if (!source) {
|
||||
return { ...item, text: message.text, clientMessageId: message.id };
|
||||
}
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: `paseo-agent-message:${message.id}`,
|
||||
agentMessage: {
|
||||
event: source.kind === "agent-message" ? "message" : source.event,
|
||||
sender: { id: source.agentId, ...(source.title ? { title: source.title } : {}) },
|
||||
text: message.text,
|
||||
},
|
||||
// COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08
|
||||
// once clients can accept a dedicated timeline variant. Older clients still
|
||||
// parse this existing shape and can expand the delivered body.
|
||||
name: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: message.text, icon: "bot" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a body in <paseo-system>…</paseo-system> so the receiving agent
|
||||
* recognizes the prompt as system-injected context — not a user turn.
|
||||
* Used by schedule fires; agent messages use the versioned envelope.
|
||||
*/
|
||||
export function formatSystemNotificationPrompt(reason: string): string {
|
||||
return `<paseo-system>\n${reason}\n</paseo-system>`;
|
||||
}
|
||||
|
||||
const SYSTEM_ENVELOPE_PATTERN = /^<paseo-system>\n[\s\S]*\n<\/paseo-system>$/;
|
||||
|
||||
export function isSystemInjectedEnvelope(text: string): boolean {
|
||||
return SYSTEM_ENVELOPE_PATTERN.test(text);
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
formatSystemNotificationPrompt,
|
||||
isSystemInjectedEnvelope,
|
||||
parseAgentMessage,
|
||||
formatAgentMessage,
|
||||
prepareAgentMessage,
|
||||
projectAgentMessage,
|
||||
} from "./agent-messages/index.js";
|
||||
import { expect, it, test, vi } from "vitest";
|
||||
import pino, { type Logger } from "pino";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -8,12 +16,7 @@ import { join } from "node:path";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import {
|
||||
formatSystemNotificationPrompt,
|
||||
isSystemInjectedEnvelope,
|
||||
setupFinishNotification,
|
||||
waitForAgentRunStartWithTimeout,
|
||||
} from "./agent-prompt.js";
|
||||
import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js";
|
||||
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
@@ -272,11 +275,16 @@ test("finish notifications tell the parent the child's last assistant message",
|
||||
scenario.startWatchingChild();
|
||||
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
|
||||
|
||||
expect(parentPrompt).toEqual(
|
||||
formatSystemNotificationPrompt(
|
||||
"Agent child-agent (Child Agent) finished.\n\n<agent-response>\nImplemented the cleanup and all checks pass.\n</agent-response>",
|
||||
),
|
||||
);
|
||||
expect(parseAgentMessage(parentPrompt)).toEqual({
|
||||
id: expect.any(String),
|
||||
source: {
|
||||
kind: "agent-notification",
|
||||
agentId: "child-agent",
|
||||
title: "Child Agent",
|
||||
event: "finished",
|
||||
},
|
||||
text: "Agent child-agent (Child Agent) finished.\n\n<agent-response>\nImplemented the cleanup and all checks pass.\n</agent-response>",
|
||||
});
|
||||
expect(scenario.steerAttemptCount()).toBe(1);
|
||||
});
|
||||
|
||||
@@ -303,9 +311,16 @@ test("closing a watched child notifies the caller", async () => {
|
||||
scenario.startWatchingChild();
|
||||
const parentPrompt = await scenario.closeChildAndReadParentPrompt();
|
||||
|
||||
expect(parentPrompt).toEqual(
|
||||
formatSystemNotificationPrompt("Agent child-agent (Child Agent) was closed."),
|
||||
);
|
||||
expect(parseAgentMessage(parentPrompt)).toEqual({
|
||||
id: expect.any(String),
|
||||
source: {
|
||||
kind: "agent-notification",
|
||||
agentId: "child-agent",
|
||||
title: "Child Agent",
|
||||
event: "closed",
|
||||
},
|
||||
text: "Agent child-agent (Child Agent) was closed.",
|
||||
});
|
||||
});
|
||||
|
||||
test("finish notifications survive permission responses", async () => {
|
||||
@@ -318,9 +333,9 @@ test("finish notifications survive permission responses", async () => {
|
||||
expect(scenario.parentPrompts()).toHaveLength(1);
|
||||
});
|
||||
expect(scenario.parentPrompts()[0]).toContain("needs permission.");
|
||||
const permissionPayload = scenario
|
||||
.parentPrompts()[0]
|
||||
.match(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/)?.[1];
|
||||
const permissionPayload = parseAgentMessage(scenario.parentPrompts()[0])!.text.match(
|
||||
/<permission-request>\n([\s\S]+?)\n<\/permission-request>/,
|
||||
)?.[1];
|
||||
expect(permissionPayload).toBeDefined();
|
||||
expect(JSON.parse(permissionPayload!)).toEqual({
|
||||
agentId: "child-agent",
|
||||
@@ -377,7 +392,9 @@ test("finish notifications report every concurrently pending permission", async
|
||||
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
|
||||
expect(
|
||||
scenario.parentPrompts().map((prompt) => {
|
||||
const payload = prompt.match(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/)?.[1];
|
||||
const payload = parseAgentMessage(prompt)!.text.match(
|
||||
/<permission-request>\n([\s\S]+?)\n<\/permission-request>/,
|
||||
)?.[1];
|
||||
return JSON.parse(payload!).requestId;
|
||||
}),
|
||||
).toEqual(["permission-1", "permission-2"]);
|
||||
@@ -762,3 +779,75 @@ test("waiting for a run start still gives up at the run start budget", async ()
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("agent envelopes round-trip opaque sender IDs and XML-sensitive messages", () => {
|
||||
const message = {
|
||||
id: "delivery-1",
|
||||
source: {
|
||||
kind: "agent-message" as const,
|
||||
agentId: 'host::agent<&"',
|
||||
title: 'QA <messenger> & "reviewer"',
|
||||
},
|
||||
text: 'Review <changes> & "quotes"\n</paseo-system>\n<paseo-system>nested</paseo-system>',
|
||||
};
|
||||
const encoded = formatAgentMessage(message);
|
||||
expect(parseAgentMessage(encoded)).toEqual(message);
|
||||
expect(parseAgentMessage(`Example: ${encoded}`)).toBeNull();
|
||||
expect(parseAgentMessage(encoded.replace('version="1"', 'version="2"'))).toBeNull();
|
||||
expect(parseAgentMessage(encoded.replace("<", "<"))).toBeNull();
|
||||
expect(parseAgentMessage(encoded.replace('version="1"', 'version="1" version="1"'))).toBeNull();
|
||||
});
|
||||
|
||||
test("agent envelope includes rendered attachment context and preserves images", () => {
|
||||
const image = { type: "image" as const, data: "aW1hZ2U=", mimeType: "image/png" };
|
||||
const attachment = {
|
||||
type: "github_issue" as const,
|
||||
mimeType: "application/github-issue" as const,
|
||||
number: 42,
|
||||
title: "Review context",
|
||||
url: "https://example.com/issues/42",
|
||||
body: "Attached context",
|
||||
};
|
||||
const source = { kind: "agent-message" as const, agentId: "remote:sender" };
|
||||
expect(
|
||||
prepareAgentMessage(
|
||||
[{ type: "text", text: "First" }, image, attachment, { type: "text", text: "Second" }],
|
||||
source,
|
||||
"message",
|
||||
),
|
||||
).toEqual({
|
||||
messageId: "message",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: formatAgentMessage({
|
||||
id: "message",
|
||||
source,
|
||||
text: "First\n\nGitHub Issue #42: Review context\nhttps://example.com/issues/42\n\nAttached context\n\nSecond",
|
||||
}),
|
||||
},
|
||||
image,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("a human pasting an agent envelope stays a human message through provider replay", () => {
|
||||
const pasted = formatAgentMessage({
|
||||
id: "example",
|
||||
source: { kind: "agent-message", agentId: "sender" },
|
||||
text: "hello",
|
||||
});
|
||||
const delivery = prepareAgentMessage(pasted, undefined, "human-submission");
|
||||
expect(
|
||||
projectAgentMessage({
|
||||
type: "user_message",
|
||||
text: String(delivery.prompt),
|
||||
messageId: "provider-echo",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "user_message",
|
||||
text: pasted,
|
||||
messageId: "provider-echo",
|
||||
clientMessageId: "human-submission",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type {
|
||||
@@ -206,27 +207,13 @@ export async function unarchiveAgentState(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a body in <paseo-system>…</paseo-system> so the receiving agent
|
||||
* recognizes the prompt as system-injected context — not a user turn.
|
||||
* Used by chat mentions, schedule fires, and notify-on-finish.
|
||||
*/
|
||||
export function formatSystemNotificationPrompt(reason: string): string {
|
||||
return `<paseo-system>\n${reason}\n</paseo-system>`;
|
||||
}
|
||||
|
||||
const SYSTEM_ENVELOPE_PATTERN = /^<paseo-system>\n[\s\S]*\n<\/paseo-system>$/;
|
||||
|
||||
export function isSystemInjectedEnvelope(text: string): boolean {
|
||||
return SYSTEM_ENVELOPE_PATTERN.test(text);
|
||||
}
|
||||
|
||||
export interface SendPromptToAgentParams {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentId: string;
|
||||
/** Prompt to dispatch to the provider (may include image blocks or wrapped text). */
|
||||
prompt: AgentPromptInput;
|
||||
source?: AgentPromptSource;
|
||||
messageId?: string;
|
||||
activeTurnBehavior?: ActiveTurnBehavior;
|
||||
runOptions?: AgentRunOptions;
|
||||
@@ -244,10 +231,12 @@ export interface SendPromptToAgentParams {
|
||||
}
|
||||
|
||||
export interface StartCreatedAgentInitialPromptParams {
|
||||
agentStorage: AgentStorage;
|
||||
agentManager: AgentManager;
|
||||
agentId: string;
|
||||
snapshot?: ManagedAgent;
|
||||
prompt: AgentPromptInput | null;
|
||||
source?: AgentPromptSource;
|
||||
runOptions?: AgentRunOptions;
|
||||
logger: Logger;
|
||||
}
|
||||
@@ -292,6 +281,18 @@ export async function waitForAgentRunStartWithTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePromptSource(
|
||||
source: AgentPromptSource | undefined,
|
||||
manager: Pick<AgentManager, "getAgent">,
|
||||
storage: AgentStorage,
|
||||
): Promise<AgentPromptSource | undefined> {
|
||||
if (!source) return undefined;
|
||||
const title = (
|
||||
manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title
|
||||
)?.trim();
|
||||
return title ? { ...source, title } : source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full send-prompt orchestration: (optional unarchive) → load → (optional
|
||||
* mode change) → start run.
|
||||
@@ -338,11 +339,13 @@ export async function sendPromptToAgent(
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
}
|
||||
|
||||
const runOptions = params.messageId
|
||||
? { ...params.runOptions, clientMessageId: params.messageId }
|
||||
const source = await resolvePromptSource(params.source, params.agentManager, params.agentStorage);
|
||||
const delivery = prepareAgentMessage(params.prompt, source, params.messageId);
|
||||
const runOptions = delivery.messageId
|
||||
? { ...params.runOptions, clientMessageId: delivery.messageId }
|
||||
: params.runOptions;
|
||||
|
||||
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
|
||||
return await startAgentRun(params.agentManager, params.agentId, delivery.prompt, params.logger, {
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: params.activeTurnBehavior,
|
||||
clearPendingPermissions: params.clearPendingPermissions,
|
||||
@@ -362,14 +365,17 @@ export async function startCreatedAgentInitialPrompt(
|
||||
return currentSnapshot;
|
||||
}
|
||||
|
||||
const delivery = prepareAgentMessage(
|
||||
params.prompt,
|
||||
await resolvePromptSource(params.source, params.agentManager, params.agentStorage),
|
||||
params.runOptions?.clientMessageId,
|
||||
);
|
||||
const dispatchResult = await startAgentRun(
|
||||
params.agentManager,
|
||||
params.agentId,
|
||||
params.prompt,
|
||||
delivery.prompt,
|
||||
params.logger,
|
||||
{
|
||||
runOptions: params.runOptions,
|
||||
},
|
||||
{ runOptions: { ...params.runOptions, clientMessageId: delivery.messageId } },
|
||||
);
|
||||
|
||||
if (dispatchResult.disposition === "turn_started") {
|
||||
@@ -394,6 +400,13 @@ export interface SetupFinishNotificationParams {
|
||||
|
||||
type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed";
|
||||
|
||||
const finishNotificationEvents = {
|
||||
finished: "finished",
|
||||
errored: "errored",
|
||||
"needs permission": "permission-required",
|
||||
"was closed": "closed",
|
||||
} as const;
|
||||
|
||||
const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000;
|
||||
|
||||
interface FinishNotificationBodyInput {
|
||||
@@ -499,7 +512,12 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
agentManager,
|
||||
agentStorage,
|
||||
agentId: callerAgentId,
|
||||
prompt: formatSystemNotificationPrompt(body),
|
||||
prompt: body,
|
||||
source: {
|
||||
kind: "agent-notification",
|
||||
agentId: childAgentId,
|
||||
event: finishNotificationEvents[reason],
|
||||
},
|
||||
activeTurnBehavior: "steer",
|
||||
unarchive: false,
|
||||
logger,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentPromptSource } from "../agent-messages/index.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||
@@ -61,6 +62,7 @@ export interface CreateAgentFromSessionInput {
|
||||
workspaceId: string;
|
||||
worktreeName?: string;
|
||||
initialPrompt?: string;
|
||||
source?: AgentPromptSource;
|
||||
clientMessageId?: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
@@ -164,6 +166,7 @@ interface ResolvedCreateAgent {
|
||||
config: AgentSessionConfig;
|
||||
createOptions: CreateAgentOptions;
|
||||
prompt?: AgentPromptInput;
|
||||
source?: AgentPromptSource;
|
||||
runOptions?: AgentRunOptions;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
background: boolean;
|
||||
@@ -293,6 +296,7 @@ async function resolveSessionCreateAgent(
|
||||
workspaceId: requireResolvedWorkspaceId(workspaceId),
|
||||
},
|
||||
prompt: hasPromptContent ? prompt : undefined,
|
||||
source: input.source,
|
||||
runOptions,
|
||||
setupContinuation,
|
||||
background: true,
|
||||
@@ -364,6 +368,9 @@ async function resolveMcpCreateAgent(
|
||||
env: input.env,
|
||||
},
|
||||
prompt: trimmedPrompt ? trimmedPrompt : undefined,
|
||||
source: input.callerAgentId
|
||||
? { kind: "agent-message", agentId: input.callerAgentId }
|
||||
: undefined,
|
||||
setupContinuation,
|
||||
createdWorktree,
|
||||
background: input.background,
|
||||
@@ -459,10 +466,12 @@ async function sendInitialPrompt(
|
||||
return { started: false, liveSnapshot: snapshot };
|
||||
}
|
||||
const liveSnapshot = await startCreatedAgentInitialPrompt({
|
||||
agentStorage: dependencies.agentStorage,
|
||||
agentManager: dependencies.agentManager,
|
||||
agentId: snapshot.id,
|
||||
snapshot,
|
||||
prompt,
|
||||
source: resolved.source,
|
||||
runOptions: resolved.runOptions,
|
||||
logger: resolved.promptLogger ?? dependencies.logger,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseAgentMessage } from "./agent-messages/index.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
@@ -1394,6 +1395,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
config: {},
|
||||
cwd: existingCwd,
|
||||
provider: "codex",
|
||||
currentModeId: "full-access",
|
||||
@@ -3136,6 +3138,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
config: {},
|
||||
cwd: existingCwd,
|
||||
workspaceId: "wks_parent",
|
||||
provider: "codex",
|
||||
@@ -3182,6 +3185,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const parentAgent = {
|
||||
id: "parent-agent",
|
||||
config: {},
|
||||
cwd: existingCwd,
|
||||
workspaceId: "wks_parent",
|
||||
provider: "codex",
|
||||
@@ -3227,6 +3231,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
config: {},
|
||||
cwd: existingCwd,
|
||||
workspaceId: "wks_parent",
|
||||
provider: "codex",
|
||||
@@ -3279,6 +3284,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
config: {},
|
||||
cwd: existingCwd,
|
||||
workspaceId: "wks_parent",
|
||||
provider: "claude",
|
||||
@@ -3593,6 +3599,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const parentAgent = {
|
||||
id: "parent-agent",
|
||||
config: {},
|
||||
cwd: existingCwd,
|
||||
workspaceId: "wks_parent",
|
||||
provider: "claude",
|
||||
@@ -3645,6 +3652,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
config: {},
|
||||
cwd: existingCwd,
|
||||
workspaceId: "wks_parent",
|
||||
provider: "claude",
|
||||
@@ -3838,6 +3846,7 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const parentAgent = {
|
||||
id: "parent-agent",
|
||||
config: {},
|
||||
cwd: existingCwd,
|
||||
workspaceId: "wks_parent",
|
||||
provider: "codex",
|
||||
@@ -3933,6 +3942,7 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const parentAgent = {
|
||||
id: "parent-agent",
|
||||
config: {},
|
||||
cwd: existingCwd,
|
||||
workspaceId: "wks_parent",
|
||||
provider: "codex",
|
||||
@@ -4133,7 +4143,12 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
agentId: child.id,
|
||||
prompt: "Follow up",
|
||||
});
|
||||
await vi.waitFor(() => expect(childSession.prompts).toEqual(["Follow up"]));
|
||||
await vi.waitFor(() => expect(childSession.prompts).toHaveLength(1));
|
||||
expect(parseAgentMessage(childSession.prompts[0])).toEqual({
|
||||
id: expect.any(String),
|
||||
source: { kind: "agent-message", agentId: parent.id },
|
||||
text: "Follow up",
|
||||
});
|
||||
acknowledgeTurnStart();
|
||||
const response = await pending;
|
||||
|
||||
|
||||
@@ -1933,6 +1933,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
agentStorage,
|
||||
agentId,
|
||||
prompt,
|
||||
source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined,
|
||||
sessionMode,
|
||||
logger: childLogger,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatSystemNotificationPrompt } from "../agent/agent-messages/index.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -7,11 +8,7 @@ import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
||||
import type { AgentStorage } from "../agent/agent-storage.js";
|
||||
import { curateAgentActivity } from "../agent/activity-curator.js";
|
||||
import { ensureAgentLoaded } from "../agent/agent-loading.js";
|
||||
import {
|
||||
formatSystemNotificationPrompt,
|
||||
startAgentRun,
|
||||
type AgentRunController,
|
||||
} from "../agent/agent-prompt.js";
|
||||
import { startAgentRun, type AgentRunController } from "../agent/agent-prompt.js";
|
||||
import { resolveCreateAgentTitles } from "../agent/create-agent-title.js";
|
||||
import { type BoundCreateAgentCommand, formatProviderModel } from "../agent/create-agent/create.js";
|
||||
import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
|
||||
@@ -4290,6 +4290,9 @@ export class Session {
|
||||
workspaceId: resolvedIntent.intent.workspaceId,
|
||||
worktreeName,
|
||||
initialPrompt,
|
||||
source: msg.callerAgentId
|
||||
? { kind: "agent-message", agentId: msg.callerAgentId }
|
||||
: undefined,
|
||||
clientMessageId,
|
||||
outputSchema,
|
||||
images,
|
||||
@@ -8094,6 +8097,9 @@ export class Session {
|
||||
agentStorage: this.agentStorage,
|
||||
agentId,
|
||||
prompt,
|
||||
source: msg.sourceAgentId
|
||||
? { kind: "agent-message", agentId: msg.sourceAgentId }
|
||||
: undefined,
|
||||
messageId: msg.messageId,
|
||||
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
|
||||
clearPendingPermissions: true,
|
||||
@@ -8111,9 +8117,13 @@ export class Session {
|
||||
await this.messageReceipts.send({
|
||||
agentId,
|
||||
messageId: msg.messageId,
|
||||
request: { prompt, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt" },
|
||||
request: {
|
||||
prompt,
|
||||
sourceAgentId: msg.sourceAgentId,
|
||||
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
|
||||
},
|
||||
prepare: async () => {
|
||||
await this.prepareAgentMessage(agentId, msg.text);
|
||||
if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text);
|
||||
},
|
||||
send,
|
||||
});
|
||||
|
||||
@@ -1923,6 +1923,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
explicitEventSubscriptions: true,
|
||||
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30.
|
||||
canonicalSubmittedPrompts: true,
|
||||
agentMessageProvenance: true,
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
stableProjectIdentity: true,
|
||||
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
|
||||
|
||||
Reference in New Issue
Block a user