Hide paseo-system prompts from the agent timeline
System-injected prompts (finish notifications, schedule fires) are wrapped in a <paseo-system> envelope and dispatched as regular user messages to the provider. The app was showing them as user-message turns. Detect the envelope at the manager boundary and skip them during live runs and history replay so only real user input renders.
This commit is contained in:
@@ -9,6 +9,7 @@ import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
|
||||
import { formatSystemNotificationPrompt } from "./agent-prompt.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
@@ -390,6 +391,50 @@ class StreamingAssistantClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeCodexEmitterArgs {
|
||||
turnItems?: AgentTimelineItem[];
|
||||
historyItems?: AgentTimelineItem[];
|
||||
}
|
||||
|
||||
function fakeCodexEmitting(args: FakeCodexEmitterArgs): AgentClient {
|
||||
const turnItems = args.turnItems ?? [];
|
||||
const historyItems = args.historyItems ?? [];
|
||||
|
||||
class FakeCodexSession extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
const turnId = "turn-fake-codex";
|
||||
setTimeout(() => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
for (const item of turnItems) {
|
||||
this.pushEvent({ type: "timeline", provider: this.provider, item, turnId });
|
||||
}
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
}, 0);
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
for (const item of historyItems) {
|
||||
yield { type: "timeline", provider: this.provider, item };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "codex",
|
||||
capabilities: TEST_CAPABILITIES,
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async createSession(config: AgentSessionConfig) {
|
||||
return new FakeCodexSession(config);
|
||||
},
|
||||
async resumeSession() {
|
||||
throw new Error("unused");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("normalizeConfig injects the provider default model when omitted", async () => {
|
||||
@@ -5419,3 +5464,75 @@ test("listImportablePersistedAgents narrows to the providerFilter when supplied"
|
||||
expect(codexClient.calls).toBe(0);
|
||||
expect(result.map((d) => d.provider)).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
const codex = fakeCodexEmitting({
|
||||
turnItems: [
|
||||
{
|
||||
type: "user_message",
|
||||
text: formatSystemNotificationPrompt("child finished"),
|
||||
},
|
||||
{ type: "user_message", text: "plain user message" },
|
||||
],
|
||||
});
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-0000000005a1",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
|
||||
await manager.runAgent(snapshot.id, { text: "do something" });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
expect(userMessages).toHaveLength(1);
|
||||
expect(userMessages[0].text).toBe("plain user message");
|
||||
});
|
||||
|
||||
test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
const codex = fakeCodexEmitting({
|
||||
historyItems: [
|
||||
{
|
||||
type: "user_message",
|
||||
text: formatSystemNotificationPrompt("schedule fired"),
|
||||
messageId: "msg_history_envelope",
|
||||
},
|
||||
{
|
||||
type: "user_message",
|
||||
text: "real user message",
|
||||
messageId: "msg_history_real",
|
||||
},
|
||||
{ type: "assistant_message", text: "reply" },
|
||||
],
|
||||
});
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-0000000005a2",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
|
||||
await manager.hydrateTimelineFromProvider(snapshot.id);
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
expect(userMessages).toHaveLength(1);
|
||||
expect(userMessages[0].text).toBe("real user message");
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-
|
||||
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||
import { IMPORTABLE_PROVIDERS } from "./provider-registry.js";
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
||||
|
||||
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
|
||||
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
||||
@@ -2622,6 +2623,9 @@ export class AgentManager {
|
||||
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
|
||||
for await (const event of agent.session.streamHistory()) {
|
||||
if (event.type === "timeline") {
|
||||
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
|
||||
continue;
|
||||
}
|
||||
historyEvents.push(event);
|
||||
}
|
||||
}
|
||||
@@ -2657,6 +2661,9 @@ export class AgentManager {
|
||||
if (event.type !== "timeline") {
|
||||
continue;
|
||||
}
|
||||
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
|
||||
continue;
|
||||
}
|
||||
this.recordTimeline(
|
||||
agent.id,
|
||||
event.item,
|
||||
@@ -2904,6 +2911,12 @@ export class AgentManager {
|
||||
}): Promise<void> {
|
||||
const { agent, event, options, flags } = params;
|
||||
|
||||
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.fromHistory) {
|
||||
this.recordTimeline(
|
||||
agent.id,
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { expect, it, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { setupFinishNotification } from "./agent-prompt.js";
|
||||
import {
|
||||
formatSystemNotificationPrompt,
|
||||
isSystemInjectedEnvelope,
|
||||
setupFinishNotification,
|
||||
} from "./agent-prompt.js";
|
||||
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||
|
||||
test("isSystemInjectedEnvelope matches the envelope formatSystemNotificationPrompt produces", () => {
|
||||
expect(isSystemInjectedEnvelope(formatSystemNotificationPrompt("child finished"))).toBe(true);
|
||||
expect(isSystemInjectedEnvelope("hello world")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not notify archived callers", async () => {
|
||||
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||
|
||||
|
||||
@@ -112,14 +112,18 @@ 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;
|
||||
/** Raw user text. Required when userMessageVisible is true. */
|
||||
userMessageText?: string;
|
||||
messageId?: string;
|
||||
runOptions?: AgentRunOptions;
|
||||
/** Optional mode to set on the agent before the run starts. */
|
||||
@@ -130,12 +134,6 @@ export interface SendPromptToAgentParams {
|
||||
* schedule fires, notify-on-finish).
|
||||
*/
|
||||
unarchive?: boolean;
|
||||
/**
|
||||
* Default true. When false, the prompt is dispatched to the provider as an
|
||||
* invisible/system prompt — the app shows no user-message turn.
|
||||
* Use false for system-injected prompts.
|
||||
*/
|
||||
userMessageVisible?: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
@@ -154,11 +152,6 @@ export async function sendPromptToAgent(
|
||||
params: SendPromptToAgentParams,
|
||||
): Promise<{ outOfBand: boolean }> {
|
||||
const unarchive = params.unarchive ?? true;
|
||||
const userMessageVisible = params.userMessageVisible ?? true;
|
||||
|
||||
if (userMessageVisible && params.userMessageText === undefined) {
|
||||
throw new Error("userMessageText is required when userMessageVisible is true");
|
||||
}
|
||||
|
||||
const record = await params.agentStorage.get(params.agentId);
|
||||
if (record?.archivedAt) {
|
||||
@@ -215,7 +208,6 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
agentId: callerAgentId,
|
||||
prompt: formatSystemNotificationPrompt(body),
|
||||
unarchive: false,
|
||||
userMessageVisible: false,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1268,7 +1268,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
agentManager,
|
||||
agentStorage,
|
||||
agentId,
|
||||
userMessageText: prompt,
|
||||
prompt,
|
||||
sessionMode,
|
||||
logger: childLogger,
|
||||
|
||||
@@ -3093,7 +3093,6 @@ export class Session {
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
agentId,
|
||||
userMessageText: text,
|
||||
prompt,
|
||||
messageId,
|
||||
runOptions,
|
||||
@@ -7783,7 +7782,6 @@ export class Session {
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
agentId,
|
||||
userMessageText: msg.text,
|
||||
prompt,
|
||||
messageId: msg.messageId,
|
||||
logger: this.sessionLogger,
|
||||
@@ -8830,7 +8828,6 @@ export class Session {
|
||||
agentId,
|
||||
prompt: formatSystemNotificationPrompt(text),
|
||||
unarchive: false,
|
||||
userMessageVisible: false,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user