diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index bdd6e7910..151db462f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -5,6 +5,7 @@ import { type AgentLifecycleStatus, } from "../../shared/agent-lifecycle.js"; import type { Logger } from "pino"; +import { getSelfIdentificationInstructions } from "./self-identification-instructions.js"; import type { AgentCapabilityFlags, @@ -1106,6 +1107,15 @@ export class AgentManager { normalized.agentControlMcp = this.agentControlMcp; } + if ( + normalized.paseoPromptInstructions === undefined && + normalized.agentControlMcp + ) { + normalized.paseoPromptInstructions = getSelfIdentificationInstructions({ + cwd: normalized.cwd, + }); + } + return normalized; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index ecd21fa9c..f92996763 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -202,6 +202,14 @@ export type AgentSessionConfig = { webSearch?: boolean; reasoningEffort?: string; agentControlMcp?: AgentControlMcpConfig; + /** + * Paseo-owned instructions injected into the first user prompt via + * .... + * + * These MUST NOT be sent via provider system/developer instructions (those are + * reserved for provider/session behaviors like resuming). + */ + paseoPromptInstructions?: string; extra?: { codex?: AgentMetadata; claude?: Partial; diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 1cecfc1de..c580e5618 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -32,6 +32,7 @@ import { } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { NotGitRepoError, renameCurrentBranch } from "../../utils/checkout-git.js"; +import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; @@ -456,8 +457,12 @@ export async function createAgentMcpServer( }); if (initialPrompt) { + const initialPromptWithInstructions = injectLeadingPaseoInstructionTag( + initialPrompt, + snapshot.config.paseoPromptInstructions + ); try { - agentManager.recordUserMessage(snapshot.id, initialPrompt); + agentManager.recordUserMessage(snapshot.id, initialPromptWithInstructions); } catch (error) { childLogger.error( { err: error, agentId: snapshot.id }, @@ -466,7 +471,7 @@ export async function createAgentMcpServer( } try { - startAgentRun(agentManager, snapshot.id, initialPrompt, childLogger); + startAgentRun(agentManager, snapshot.id, initialPromptWithInstructions, childLogger); // If not running in background, wait for completion if (!background) { diff --git a/packages/server/src/server/agent/paseo-instructions-tag.test.ts b/packages/server/src/server/agent/paseo-instructions-tag.test.ts new file mode 100644 index 000000000..09512de3a --- /dev/null +++ b/packages/server/src/server/agent/paseo-instructions-tag.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vitest"; + +import { + formatPaseoInstructionTag, + hasLeadingPaseoInstructionTag, + injectLeadingPaseoInstructionTag, + stripLeadingPaseoInstructionTag, +} from "./paseo-instructions-tag.js"; + +describe("paseo instruction tags", () => { + test("formatPaseoInstructionTag wraps content", () => { + expect(formatPaseoInstructionTag("hello")).toBe( + "\nhello\n" + ); + }); + + test("hasLeadingPaseoInstructionTag detects leading tag", () => { + expect(hasLeadingPaseoInstructionTag("\nX\n")).toBe( + true + ); + expect(hasLeadingPaseoInstructionTag("nope ")).toBe(false); + }); + + test("stripLeadingPaseoInstructionTag strips leading tag content", () => { + const input = [ + "", + "do the thing", + "", + "", + "Hello world", + ].join("\n"); + expect(stripLeadingPaseoInstructionTag(input)).toBe("Hello world"); + }); + + test("injectLeadingPaseoInstructionTag prepends instructions when missing", () => { + expect(injectLeadingPaseoInstructionTag("Hello", "do the thing")).toBe( + "\ndo the thing\n\n\nHello" + ); + }); + + test("injectLeadingPaseoInstructionTag is idempotent when tag already present", () => { + const input = "\nX\n\n\nHello"; + expect(injectLeadingPaseoInstructionTag(input, "do the thing")).toBe(input); + }); +}); diff --git a/packages/server/src/server/agent/paseo-instructions-tag.ts b/packages/server/src/server/agent/paseo-instructions-tag.ts new file mode 100644 index 000000000..548d27b4c --- /dev/null +++ b/packages/server/src/server/agent/paseo-instructions-tag.ts @@ -0,0 +1,48 @@ +const OPEN_TAG = ""; +const CLOSE_TAG = ""; + +export function formatPaseoInstructionTag(instructions: string): string { + return `${OPEN_TAG}\n${instructions}\n${CLOSE_TAG}`; +} + +export function hasLeadingPaseoInstructionTag(text: string): boolean { + return /^\s*/.test(text); +} + +/** + * Prepend paseo instructions to a prompt exactly once (idempotent by content). + * This is intended for agent creation / initial prompt only. + */ +export function injectLeadingPaseoInstructionTag( + prompt: string, + instructions: string | null | undefined +): string { + const normalizedInstructions = instructions?.trim() ?? ""; + if (!normalizedInstructions) { + return prompt; + } + if (hasLeadingPaseoInstructionTag(prompt)) { + return prompt; + } + return `${formatPaseoInstructionTag(normalizedInstructions)}\n\n${prompt}`; +} + +/** + * Remove a leading ... block, if present. + * The content is treated as internal metadata and is discarded. + */ +export function stripLeadingPaseoInstructionTag(text: string): string { + const leadingMatch = text.match(/^\s*/); + if (!leadingMatch || leadingMatch.index !== 0) { + return text; + } + + const openEnd = leadingMatch[0].length; + const closeStart = text.indexOf(CLOSE_TAG, openEnd); + if (closeStart === -1) { + return text; + } + + const closeEnd = closeStart + CLOSE_TAG.length; + return text.slice(closeEnd).trimStart(); +} diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts index cfac70697..4a5cb6348 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts @@ -14,7 +14,10 @@ import { ClaudeAgentClient } from "./claude-agent.js"; import type { AgentSession, AgentSessionConfig, AgentSlashCommand } from "../agent-sdk-types.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -describe("ClaudeAgentSession Commands", () => { +const hasClaudeCredentials = + !!process.env.CLAUDE_SESSION_TOKEN || !!process.env.ANTHROPIC_API_KEY; + +(hasClaudeCredentials ? describe : describe.skip)("ClaudeAgentSession Commands", () => { let client: ClaudeAgentClient; let session: AgentSession; diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 9a36f6248..29baf0fbf 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -48,7 +48,6 @@ import type { PersistedAgentDescriptor, } from "../agent-sdk-types.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; -import { getSelfIdentificationInstructions } from "../self-identification-instructions.js"; const fsPromises = promises; @@ -802,12 +801,7 @@ class ClaudeAgentSession implements AgentSession { systemPrompt: { type: "preset", preset: "claude_code", - append: [ - getOrchestratorModeInstructions(), - this.currentMode === "plan" ? "" : getSelfIdentificationInstructions({ cwd: this.config.cwd }), - ] - .filter(Boolean) - .join("\n"), + append: [getOrchestratorModeInstructions()].filter(Boolean).join("\n"), }, settingSources: ["user", "project"], stderr: (data: string) => { diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.paseo-instructions.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.paseo-instructions.test.ts new file mode 100644 index 000000000..fb0bd8cfb --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.paseo-instructions.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { __test__ } from "./codex-mcp-agent.js"; +import type { AgentSessionConfig } from "../agent-sdk-types.js"; + +describe("codex developer-instructions vs paseo prompt instructions", () => { + test("does not inject Paseo self-identification into developer-instructions", () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "codex-rollout-")); + const rolloutPath = path.join(dir, "rollout.jsonl"); + + const entry = { + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ input_text: "hello from history" }], + }, + }; + + writeFileSync(rolloutPath, JSON.stringify(entry) + "\n", "utf8"); + + const config: AgentSessionConfig = { + provider: "codex", + cwd: dir, + modeId: "auto", + }; + + const payload = __test__.buildCodexMcpConfig( + config, + "Hello world", + "auto", + undefined, + rolloutPath + ); + + const dev = payload["developer-instructions"] ?? ""; + expect(dev).toContain(""); + expect(dev).toContain("hello from history"); + expect(dev.toLowerCase()).not.toContain("set_title"); + expect(dev.toLowerCase()).not.toContain("set_branch"); + expect(dev.toLowerCase()).not.toContain("you are running under paseo"); + }); +}); + diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts index a43fda3fa..79a1278c4 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts @@ -1133,7 +1133,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => { model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, cwd, - modeId: "auto", + modeId: "read-only", approvalPolicy: "on-request", } satisfies AgentSessionConfig; const filePath = path.join(cwd, "permission.txt"); diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index 9a8a70628..e5d86e74f 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -39,7 +39,6 @@ import type { ListPersistedAgentsOptions, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; -import { getSelfIdentificationInstructions } from "../self-identification-instructions.js"; import { curateAgentActivity } from "../activity-curator.js"; type CodexMcpAgentConfig = AgentSessionConfig & { provider: "codex" }; @@ -2759,7 +2758,6 @@ function buildCodexMcpConfig( developerInstructions = history; } } - const selfIdentificationInstructions = getSelfIdentificationInstructions({ cwd: config.cwd }); // Build MCP servers configuration const mcpServers: Record = {}; @@ -2833,7 +2831,7 @@ function buildCodexMcpConfig( } - const combinedDeveloperInstructions = [developerInstructions, selfIdentificationInstructions] + const combinedDeveloperInstructions = [developerInstructions] .filter(Boolean) .join("\n\n"); @@ -3227,7 +3225,7 @@ class CodexMcpAgentSession implements AgentSession { const abortController = new AbortController(); this.currentAbortController = abortController; - const promptText = await toPromptText(prompt, this.logger); + let promptText = await toPromptText(prompt, this.logger); // NOTE: user_message is NOT emitted here because the agent-manager's // recordUserMessage() already handles emitting the user message timeline // event before calling stream(). Emitting here would cause duplicates. @@ -4652,6 +4650,7 @@ export const __test__ = { tokenizeCommandArgs, parseFrontMatter, expandCodexCustomPrompt, + buildCodexMcpConfig, isMissingConversationIdError, isMissingConversationIdResponse, }; diff --git a/packages/server/src/server/daemon-client-v2.e2e.test.ts b/packages/server/src/server/daemon-client-v2.e2e.test.ts index 1545a935c..b343a1e66 100644 --- a/packages/server/src/server/daemon-client-v2.e2e.test.ts +++ b/packages/server/src/server/daemon-client-v2.e2e.test.ts @@ -401,64 +401,64 @@ describe("daemon client v2 E2E", () => { }); const permissionRequestPromise = waitForSignal(60000, (resolve) => { - const unsubscribe = ctx.client.on( - "agent_permission_request", - (message) => { - if (message.type !== "agent_permission_request") { - return; - } - if (message.payload.agentId !== agent.id) { - return; - } - resolve(message); + const unsubscribe = ctx.client.on("agent_permission_request", (message) => { + if (message.type !== "agent_permission_request") { + return; } - ); + if (message.payload.agentId !== agent.id) { + return; + } + resolve(message); + }); return unsubscribe; }); - await ctx.client.sendMessage( - agent.id, - "Request approval to run the command `printf \"ok\" > permission.txt`." - ); - - const permission = await ctx.client.waitForPermission(agent.id, 60000); - expect(permission).toBeTruthy(); - expect(permission.id).toBeTruthy(); - - const permissionRequest = await permissionRequestPromise; - expect(permissionRequest.payload.agentId).toBe(agent.id); - const permissionResolvedPromise = waitForSignal(60000, (resolve) => { - const unsubscribe = ctx.client.on( - "agent_permission_resolved", - (message) => { - if (message.type !== "agent_permission_resolved") { - return; - } - if (message.payload.agentId !== agent.id) { - return; - } - if (message.payload.requestId !== permission.id) { - return; - } - resolve(message); + const unsubscribe = ctx.client.on("agent_permission_resolved", (message) => { + if (message.type !== "agent_permission_resolved") { + return; } - ); + if (message.payload.agentId !== agent.id) { + return; + } + resolve(message); + }); return unsubscribe; }); - await ctx.client.respondToPermission(agent.id, permission.id, { - behavior: "allow", - }); - const permissionResolved = await permissionResolvedPromise; - expect(permissionResolved.payload.requestId).toBe(permission.id); + try { + await ctx.client.sendMessage( + agent.id, + [ + "Use your shell tool to run: `printf \"ok\" > permission.txt`.", + "This will require approval. Request permission and wait for approval before continuing.", + ].join("\n") + ); - const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000); - expect(finalState.status).toBe("idle"); - expect(existsSync(filePath)).toBe(true); + const permission = await ctx.client.waitForPermission(agent.id, 60000); + expect(permission).toBeTruthy(); + expect(permission.id).toBeTruthy(); - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); + const permissionRequest = await permissionRequestPromise; + expect(permissionRequest.payload.agentId).toBe(agent.id); + + await ctx.client.respondToPermission(agent.id, permission.id, { + behavior: "allow", + }); + + const permissionResolved = await permissionResolvedPromise; + expect(permissionResolved.payload.requestId).toBe(permission.id); + + const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000); + expect(finalState.status).toBe("idle"); + expect(existsSync(filePath)).toBe(true); + } finally { + // Prevent unhandled rejections if the test fails before promises resolve. + await permissionRequestPromise.catch(() => {}); + await permissionResolvedPromise.catch(() => {}); + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + } }, 180000 ); diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index 6974e3b92..71073687d 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -19,6 +19,17 @@ function tmpCwd(prefix: string): string { return realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); } +function hasGitHubCliAuth(): boolean { + try { + execSync("gh auth status -h github.com", { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +const testWithGitHubCliAuth = hasGitHubCliAuth() ? test : test.skip; + type McpToolResult = { structuredContent?: Record; content?: Array<{ structuredContent?: Record } | Record>; @@ -137,7 +148,7 @@ describe("daemon checkout ship loop", () => { await ctx.cleanup(); }, 60000); - test( + testWithGitHubCliAuth( "runs the full checkout ship loop via checkout RPCs", async () => { const repoDir = tmpCwd("checkout-ship-"); diff --git a/packages/server/src/server/daemon-e2e/orchestration.e2e.test.ts b/packages/server/src/server/daemon-e2e/orchestration.e2e.test.ts index 37f990381..78dfec102 100644 --- a/packages/server/src/server/daemon-e2e/orchestration.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/orchestration.e2e.test.ts @@ -120,11 +120,36 @@ describe("daemon E2E", () => { createAgentCall.type === "tool_call" && createAgentCall.output ) { - // The output contains the agentId - const output = createAgentCall.output as { agentId?: string }; - if (output.agentId) { - childAgentId = output.agentId; - } + const output = createAgentCall.output as unknown; + const tryExtract = (value: unknown): string | null => { + if (!value) return null; + if (typeof value === "string") { + try { + return tryExtract(JSON.parse(value)); + } catch { + return null; + } + } + if (typeof value !== "object") return null; + const asObj = value as Record; + const direct = asObj.agentId; + if (typeof direct === "string") return direct; + const structured = asObj.structuredContent; + if (structured && typeof structured === "object") { + const nested = (structured as Record).agentId; + if (typeof nested === "string") return nested; + } + if (typeof structured === "string") { + try { + return tryExtract(JSON.parse(structured)); + } catch { + return null; + } + } + return null; + }; + + childAgentId = tryExtract(output); } // Verify we found the child agent ID diff --git a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts index cdfdd2799..89d58a550 100644 --- a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts @@ -40,7 +40,7 @@ describe("daemon E2E", () => { provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, cwd, title: "Codex Permission Test", - modeId: "auto", + modeId: "read-only", }); expect(agent.id).toBeTruthy(); @@ -105,7 +105,7 @@ describe("daemon E2E", () => { provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, cwd, title: "Codex Permission Deny Test", - modeId: "auto", + modeId: "read-only", }); expect(agent.id).toBeTruthy(); diff --git a/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts b/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts index 0ed05d292..9d3617a87 100644 --- a/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts @@ -17,7 +17,12 @@ function tmpCwd(): string { const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; const CODEX_TEST_REASONING_EFFORT = "low"; -describe("daemon E2E", () => { +const hasClaudeCredentials = + !!process.env.CLAUDE_SESSION_TOKEN || !!process.env.ANTHROPIC_API_KEY; + +const describeWithClaude = hasClaudeCredentials ? describe : describe.skip; + +describeWithClaude("daemon E2E", () => { let ctx: DaemonTestContext; beforeEach(async () => { @@ -389,43 +394,26 @@ describe("daemon E2E", () => { const queue = ctx.client.getMessageQueue(); const assistantChunks: string[] = []; - // Debug: dump all events from queue - - for (let i = 0; i < queue.length; i++) { - const m = queue[i]; - if (m.type === "agent_stream" && m.payload.agentId === agent.id) { - const event = m.payload.event; - if (event.type === "timeline") { - const item = event.item; - - } else { - - } - } else if (m.type === "agent_state" && m.payload.id === agent.id) { - - } - } - - // Find the user_message for message 2 to mark the boundary - let foundMsg2UserMessage = false; + // We only care about Turn 2 ("Hello world ..."), but event ordering can be noisy when + // Turn 1 is still streaming. Anchor on the assistant response content itself. + let startedCollecting = false; for (let i = msg2StartPosition; i < queue.length; i++) { const m = queue[i]; - - // Look for our user message to mark the start of message 2 context if ( m.type === "agent_stream" && m.payload.agentId === agent.id && m.payload.event.type === "timeline" ) { const item = m.payload.event.item; - if (item.type === "user_message" && (item.text as string)?.includes("Hello world")) { - foundMsg2UserMessage = true; - - } - // Collect assistant messages after we found the user message - if (foundMsg2UserMessage && item.type === "assistant_message" && item.text) { - assistantChunks.push(item.text); + if (item.type === "assistant_message" && item.text) { + const text = String(item.text); + if (!startedCollecting && text.includes("Hello")) { + startedCollecting = true; + } + if (startedCollecting) { + assistantChunks.push(text); + } } } } diff --git a/packages/server/src/server/messages.test.ts b/packages/server/src/server/messages.test.ts new file mode 100644 index 000000000..3f0ce9025 --- /dev/null +++ b/packages/server/src/server/messages.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; + +import { serializeAgentStreamEvent } from "./messages.js"; + +describe("serializeAgentStreamEvent", () => { + test("strips leading paseo-instructions from user_message timeline items", () => { + const event = { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "\nX\n\n\nHello", + messageId: "m1", + }, + } as any; + + const serialized = serializeAgentStreamEvent(event) as any; + expect(serialized.item.text).toBe("Hello"); + expect(serialized.item.messageId).toBe("m1"); + }); + + test("does not strip non-leading tags", () => { + const event = { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "Hello \nX\n", + }, + } as any; + + const serialized = serializeAgentStreamEvent(event) as any; + expect(serialized.item.text).toBe(event.item.text); + }); +}); + diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index a0b594066..5d11e4111 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -1,6 +1,7 @@ import type { ManagedAgent } from "./agent/agent-manager.js"; import { toAgentPayload } from "./agent/agent-projections.js"; import type { AgentStreamEvent } from "./agent/agent-sdk-types.js"; +import { stripLeadingPaseoInstructionTag } from "./agent/paseo-instructions-tag.js"; import type { AgentSnapshotPayload, AgentStreamEventPayload, @@ -18,5 +19,21 @@ export function serializeAgentSnapshot( export function serializeAgentStreamEvent( event: AgentStreamEvent ): AgentStreamEventPayload { - return event as AgentStreamEventPayload; + if (event.type !== "timeline") { + return event as AgentStreamEventPayload; + } + if (event.item.type !== "user_message") { + return event as AgentStreamEventPayload; + } + const stripped = stripLeadingPaseoInstructionTag(event.item.text); + if (stripped === event.item.text) { + return event as AgentStreamEventPayload; + } + return { + ...event, + item: { + ...event.item, + text: stripped, + }, + } as AgentStreamEventPayload; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 99fd89e59..8e7b9013e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -45,6 +45,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { buildProviderRegistry } from "./agent/provider-registry.js"; import { AgentManager } from "./agent/agent-manager.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; +import { injectLeadingPaseoInstructionTag } from "./agent/paseo-instructions-tag.js"; import { toAgentPayload } from "./agent/agent-projections.js"; import { StructuredAgentResponseError, @@ -1322,9 +1323,13 @@ export class Session { const trimmedPrompt = initialPrompt?.trim(); if (trimmedPrompt) { try { + const initialPromptWithInstructions = injectLeadingPaseoInstructionTag( + trimmedPrompt, + snapshot.config.paseoPromptInstructions + ); await this.handleSendAgentMessage( snapshot.id, - trimmedPrompt, + initialPromptWithInstructions, uuidv4(), images ); diff --git a/packages/server/src/server/voice-conversations.e2e.test.ts b/packages/server/src/server/voice-conversations.e2e.test.ts index 4654e13d9..8557315b7 100644 --- a/packages/server/src/server/voice-conversations.e2e.test.ts +++ b/packages/server/src/server/voice-conversations.e2e.test.ts @@ -20,6 +20,30 @@ async function waitForFile(filepath: string, timeoutMs = 5000): Promise { } } +async function waitForJsonFile( + filepath: string, + timeoutMs = 5000 +): Promise { + const start = Date.now(); + // eslint-disable-next-line no-constant-condition + while (true) { + if (existsSync(filepath)) { + try { + const raw = readFileSync(filepath, "utf8"); + if (raw.trim().length > 0) { + return JSON.parse(raw) as T; + } + } catch { + // File may exist but still be mid-write; retry. + } + } + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for valid JSON: ${filepath}`); + } + await new Promise((r) => setTimeout(r, 50)); + } +} + describe("voice conversations - daemon E2E", () => { test( "two concurrent clients persist independently under paseoHome/voice-conversations", @@ -57,16 +81,16 @@ describe("voice conversations - daemon E2E", () => { await waitForFile(fileA); await waitForFile(fileB); - const dataA = JSON.parse(readFileSync(fileA, "utf8")) as { + const dataA = await waitForJsonFile<{ voiceConversationId: string; messageCount: number; messages: unknown[]; - }; - const dataB = JSON.parse(readFileSync(fileB, "utf8")) as { + }>(fileA); + const dataB = await waitForJsonFile<{ voiceConversationId: string; messageCount: number; messages: unknown[]; - }; + }>(fileB); expect(dataA.voiceConversationId).toBe(voiceConversationIdA); expect(dataB.voiceConversationId).toBe(voiceConversationIdB); @@ -121,4 +145,3 @@ describe("voice conversations - daemon E2E", () => { 30000 ); }); -