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:
Mohamed Boudra
2026-10-08 14:42:34 +01:00
committed by GitHub
parent 06fe97c901
commit d0bc188a5b
29 changed files with 877 additions and 94 deletions
+19 -5
View File
@@ -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
+1
View File
@@ -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}
+9 -5
View File
@@ -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");
});
+14 -5
View File
@@ -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,
};
+25
View File
@@ -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 } } });
});
+3
View File
@@ -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,
+4 -1
View File
@@ -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) {
+46
View File
@@ -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;
},
);
+8
View File
@@ -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}`,
},
};
}
+13
View File
@@ -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>;
+2
View File
@@ -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;
+28
View File
@@ -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);
});
+5
View File
@@ -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.
+5 -1
View File
@@ -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 &lt;changes&gt; &amp; 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 "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&apos;";
}
});
}
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("&lt;", "<"))).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";
+12 -2
View File
@@ -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.