mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'fix/initial-prompt-instructions'
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,14 @@ export type AgentSessionConfig = {
|
||||
webSearch?: boolean;
|
||||
reasoningEffort?: string;
|
||||
agentControlMcp?: AgentControlMcpConfig;
|
||||
/**
|
||||
* Paseo-owned instructions injected into the first user prompt via
|
||||
* <paseo-instructions>...</paseo-instructions>.
|
||||
*
|
||||
* 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<ClaudeAgentOptions>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
"<paseo-instructions>\nhello\n</paseo-instructions>"
|
||||
);
|
||||
});
|
||||
|
||||
test("hasLeadingPaseoInstructionTag detects leading tag", () => {
|
||||
expect(hasLeadingPaseoInstructionTag("<paseo-instructions>\nX\n</paseo-instructions>")).toBe(
|
||||
true
|
||||
);
|
||||
expect(hasLeadingPaseoInstructionTag("nope <paseo-instructions>")).toBe(false);
|
||||
});
|
||||
|
||||
test("stripLeadingPaseoInstructionTag strips leading tag content", () => {
|
||||
const input = [
|
||||
"<paseo-instructions>",
|
||||
"do the thing",
|
||||
"</paseo-instructions>",
|
||||
"",
|
||||
"Hello world",
|
||||
].join("\n");
|
||||
expect(stripLeadingPaseoInstructionTag(input)).toBe("Hello world");
|
||||
});
|
||||
|
||||
test("injectLeadingPaseoInstructionTag prepends instructions when missing", () => {
|
||||
expect(injectLeadingPaseoInstructionTag("Hello", "do the thing")).toBe(
|
||||
"<paseo-instructions>\ndo the thing\n</paseo-instructions>\n\nHello"
|
||||
);
|
||||
});
|
||||
|
||||
test("injectLeadingPaseoInstructionTag is idempotent when tag already present", () => {
|
||||
const input = "<paseo-instructions>\nX\n</paseo-instructions>\n\nHello";
|
||||
expect(injectLeadingPaseoInstructionTag(input, "do the thing")).toBe(input);
|
||||
});
|
||||
});
|
||||
48
packages/server/src/server/agent/paseo-instructions-tag.ts
Normal file
48
packages/server/src/server/agent/paseo-instructions-tag.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
const OPEN_TAG = "<paseo-instructions>";
|
||||
const CLOSE_TAG = "</paseo-instructions>";
|
||||
|
||||
export function formatPaseoInstructionTag(instructions: string): string {
|
||||
return `${OPEN_TAG}\n${instructions}\n${CLOSE_TAG}`;
|
||||
}
|
||||
|
||||
export function hasLeadingPaseoInstructionTag(text: string): boolean {
|
||||
return /^\s*<paseo-instructions>/.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 <paseo-instructions>...</paseo-instructions> block, if present.
|
||||
* The content is treated as internal metadata and is discarded.
|
||||
*/
|
||||
export function stripLeadingPaseoInstructionTag(text: string): string {
|
||||
const leadingMatch = text.match(/^\s*<paseo-instructions>/);
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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("<previous_conversation>");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, CodexMcpServerConfig> = {};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
content?: Array<{ structuredContent?: Record<string, unknown> } | Record<string, unknown>>;
|
||||
@@ -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-");
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const direct = asObj.agentId;
|
||||
if (typeof direct === "string") return direct;
|
||||
const structured = asObj.structuredContent;
|
||||
if (structured && typeof structured === "object") {
|
||||
const nested = (structured as Record<string, unknown>).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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
packages/server/src/server/messages.test.ts
Normal file
36
packages/server/src/server/messages.test.ts
Normal file
@@ -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: "<paseo-instructions>\nX\n</paseo-instructions>\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 <paseo-instructions>\nX\n</paseo-instructions>",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const serialized = serializeAgentStreamEvent(event) as any;
|
||||
expect(serialized.item.text).toBe(event.item.text);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -20,6 +20,30 @@ async function waitForFile(filepath: string, timeoutMs = 5000): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForJsonFile<T>(
|
||||
filepath: string,
|
||||
timeoutMs = 5000
|
||||
): Promise<T> {
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user