Update files
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const server = new McpServer({ name: "paseo-test-mcp", version: "1.0.0" });
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"echo",
|
||||||
|
{ text: z.string() },
|
||||||
|
async ({ text }) => ({
|
||||||
|
content: [{ type: "text", text: `ECHO:${text}` }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
@@ -376,5 +376,20 @@ describe("curateAgentActivity", () => {
|
|||||||
'[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}'
|
'[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows namespaced tool input JSON regardless of prefix format", () => {
|
||||||
|
const timeline: AgentTimelineItem[] = [
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "m2",
|
||||||
|
name: "paseo_voice.speak",
|
||||||
|
input: { text: "hello from namespaced tool" },
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = curateAgentActivity(timeline);
|
||||||
|
expect(result).toBe('[paseo_voice.speak] {"text":"hello from namespaced tool"}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||||
import { extractPrincipalParam } from "../../utils/tool-call-parsers.js";
|
import { extractPrincipalParam } from "../../utils/tool-call-parsers.js";
|
||||||
|
import { isLikelyExternalToolName } from "./tool-name-normalization.js";
|
||||||
|
|
||||||
const DEFAULT_MAX_ITEMS = 40;
|
const DEFAULT_MAX_ITEMS = 40;
|
||||||
const MAX_TOOL_INPUT_CHARS = 400;
|
const MAX_TOOL_INPUT_CHARS = 400;
|
||||||
@@ -26,17 +27,6 @@ function flushBuffers(lines: string[], buffers: { message: string; thought: stri
|
|||||||
buffers.thought = "";
|
buffers.thought = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLikelyMcpToolCall(name: string): boolean {
|
|
||||||
const normalized = name.toLowerCase();
|
|
||||||
return (
|
|
||||||
normalized === "speak" ||
|
|
||||||
normalized.startsWith("mcp") ||
|
|
||||||
normalized.includes("mcp__") ||
|
|
||||||
normalized.startsWith("paseo") ||
|
|
||||||
normalized.includes("paseo__")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatToolInputJson(input: unknown): string | null {
|
function formatToolInputJson(input: unknown): string | null {
|
||||||
if (input === undefined) {
|
if (input === undefined) {
|
||||||
return null;
|
return null;
|
||||||
@@ -158,7 +148,7 @@ export function curateAgentActivity(
|
|||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
flushBuffers(lines, buffers);
|
flushBuffers(lines, buffers);
|
||||||
const inputJson = formatToolInputJson(item.input);
|
const inputJson = formatToolInputJson(item.input);
|
||||||
if (isLikelyMcpToolCall(item.name) && inputJson) {
|
if (isLikelyExternalToolName(item.name) && inputJson) {
|
||||||
lines.push(`[${item.name}] ${inputJson}`);
|
lines.push(`[${item.name}] ${inputJson}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,77 @@ describe("Codex app-server provider (integration)", () => {
|
|||||||
}
|
}
|
||||||
}, 120000);
|
}, 120000);
|
||||||
|
|
||||||
|
test.runIf(isCodexInstalled())("round-trips a stdio MCP tool call", async () => {
|
||||||
|
const cleanup = useTempCodexSessionDir();
|
||||||
|
const cwd = tmpCwd("codex-mcp-roundtrip-");
|
||||||
|
const token = `MCP_ROUNDTRIP_${Date.now()}`;
|
||||||
|
const mcpScriptPath = path.resolve(process.cwd(), "scripts", "mcp-echo-test-server.mjs");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new CodexAppServerAgentClient(logger);
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "codex",
|
||||||
|
cwd,
|
||||||
|
modeId: "read-only",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
|
mcpServers: {
|
||||||
|
paseo_test: {
|
||||||
|
type: "stdio",
|
||||||
|
command: process.execPath,
|
||||||
|
args: [mcpScriptPath],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await session.run(
|
||||||
|
[
|
||||||
|
"You must call the MCP tool named paseo_test.echo exactly once.",
|
||||||
|
`Call it with text: ${token}`,
|
||||||
|
"Do not use shell or any non-MCP tools.",
|
||||||
|
"After the tool call, respond with exactly the tool output text.",
|
||||||
|
].join(" ")
|
||||||
|
);
|
||||||
|
await session.close();
|
||||||
|
|
||||||
|
const toolCalls = result.timeline.filter(
|
||||||
|
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||||
|
item.type === "tool_call"
|
||||||
|
);
|
||||||
|
const toolNames = toolCalls.map((item) => item.name);
|
||||||
|
const distinctMcpCalls = new Map<string, Extract<AgentTimelineItem, { type: "tool_call" }>>();
|
||||||
|
for (const call of toolCalls) {
|
||||||
|
if (call.name !== "paseo_test.echo") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(call.callId ?? `${call.name}:${JSON.stringify(call.input ?? {})}`);
|
||||||
|
const existing = distinctMcpCalls.get(key);
|
||||||
|
if (!existing || call.status === "completed") {
|
||||||
|
distinctMcpCalls.set(key, call);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard assertion: exactly one distinct call of the exact MCP tool.
|
||||||
|
expect(toolNames.every((name) => name === "paseo_test.echo")).toBe(true);
|
||||||
|
expect(distinctMcpCalls.size).toBe(1);
|
||||||
|
const mcpToolCall = Array.from(distinctMcpCalls.values())[0]!;
|
||||||
|
expect(mcpToolCall.name).toBe("paseo_test.echo");
|
||||||
|
expect(mcpToolCall.status).toBe("completed");
|
||||||
|
|
||||||
|
// Hard assertion: no non-MCP tools in this run.
|
||||||
|
expect(toolNames.every((name) => name === "paseo_test.echo")).toBe(true);
|
||||||
|
expect(toolNames.some((name) => name.toLowerCase().includes("shell"))).toBe(false);
|
||||||
|
|
||||||
|
// Hard assertion: roundtrip token must be present in the MCP tool I/O.
|
||||||
|
expect(JSON.stringify(mcpToolCall.input ?? {})).toContain(token);
|
||||||
|
expect(JSON.stringify(mcpToolCall.output ?? {})).toContain(`ECHO:${token}`);
|
||||||
|
expect(result.finalText).toContain(`ECHO:${token}`);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
test.runIf(isCodexInstalled())("listCommands includes custom prompts and executeCommand runs them", async () => {
|
test.runIf(isCodexInstalled())("listCommands includes custom prompts and executeCommand runs them", async () => {
|
||||||
const cleanup = useTempCodexSessionDir();
|
const cleanup = useTempCodexSessionDir();
|
||||||
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { loadCodexPersistedTimeline } from "./codex-mcp-agent.js";
|
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
|
||||||
|
|
||||||
import { __test__ } from "./codex-mcp-agent.js";
|
|
||||||
|
|
||||||
describe("codex custom command plumbing", () => {
|
|
||||||
test("parseFrontMatter extracts metadata and body", () => {
|
|
||||||
const input = [
|
|
||||||
"---",
|
|
||||||
"description: Hello",
|
|
||||||
"argument-hint: NAME=<name>",
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
"Say hi to $NAME",
|
|
||||||
"",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const parsed = __test__.parseFrontMatter(input);
|
|
||||||
expect(parsed.frontMatter["description"]).toBe("Hello");
|
|
||||||
expect(parsed.frontMatter["argument-hint"]).toBe("NAME=<name>");
|
|
||||||
expect(parsed.body).toContain("Say hi to $NAME");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("expandCodexCustomPrompt supports named + positional placeholders", () => {
|
|
||||||
const template = "A=$A B=$B 1=$1 2=$2 ARGS=$ARGUMENTS $$=$$";
|
|
||||||
const out = __test__.expandCodexCustomPrompt(template, "A=hello B=world one two");
|
|
||||||
expect(out).toContain("A=hello");
|
|
||||||
expect(out).toContain("B=world");
|
|
||||||
expect(out).toContain("1=one");
|
|
||||||
expect(out).toContain("2=two");
|
|
||||||
expect(out).toContain("ARGS=A=hello B=world one two");
|
|
||||||
expect(out).toContain("$=$");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tokenizeCommandArgs respects quotes", () => {
|
|
||||||
const tokens = __test__.tokenizeCommandArgs('A=\"hello world\" B=two three');
|
|
||||||
expect(tokens).toEqual(["A=hello world", "B=two", "three"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { __test__ } from "./codex-mcp-agent.js";
|
|
||||||
|
|
||||||
describe("codex resume error detection", () => {
|
|
||||||
test("detects missing session errors for thread_id", () => {
|
|
||||||
const error = new Error(
|
|
||||||
"Session not found for thread_id: 019bda3e-ffe4-7bc1-ae0e-9b992d7c9360"
|
|
||||||
);
|
|
||||||
expect(__test__.isMissingConversationIdError(error)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("detects missing session responses for thread_id", () => {
|
|
||||||
const response = {
|
|
||||||
content: [{ type: "text", text: "Session not found for thread_id: abc" }],
|
|
||||||
};
|
|
||||||
expect(__test__.isMissingConversationIdResponse(response)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|||||||
import { rmSync, mkdtempSync, writeFileSync } from "node:fs";
|
import { rmSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { parseRolloutFile } from "./codex-mcp-agent.js";
|
import { parseRolloutFile } from "./codex-rollout-timeline.js";
|
||||||
|
|
||||||
describe("codex rollout parsing", () => {
|
describe("codex rollout parsing", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import { promises as fs, type Dirent } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
|
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||||
|
|
||||||
|
const MAX_ROLLOUT_SEARCH_DEPTH = 4;
|
||||||
|
|
||||||
|
function resolveCodexSessionRoot(): string | null {
|
||||||
|
if (process.env.CODEX_SESSION_DIR) {
|
||||||
|
return process.env.CODEX_SESSION_DIR;
|
||||||
|
}
|
||||||
|
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||||
|
return path.join(codexHome, "sessions");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findRolloutFile(
|
||||||
|
threadId: string,
|
||||||
|
root: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const stack: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const { dir, depth } = stack.pop()!;
|
||||||
|
let entries: Dirent[];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isFile()) {
|
||||||
|
const matchesThread = entry.name.includes(threadId);
|
||||||
|
const matchesPrefix = entry.name.startsWith("rollout-");
|
||||||
|
const matchesExtension =
|
||||||
|
entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
|
||||||
|
if (matchesThread && matchesPrefix && matchesExtension) {
|
||||||
|
return entryPath;
|
||||||
|
}
|
||||||
|
} else if (entry.isDirectory() && depth < MAX_ROLLOUT_SEARCH_DEPTH) {
|
||||||
|
stack.push({ dir: entryPath, depth: depth + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RolloutContentItemSchema = z
|
||||||
|
.union([
|
||||||
|
z.object({ type: z.literal("input_text"), text: z.string() }),
|
||||||
|
z.object({ type: z.literal("output_text"), text: z.string() }),
|
||||||
|
z.object({ type: z.literal("reasoning_text"), text: z.string() }),
|
||||||
|
z.object({ type: z.literal("text"), text: z.string() }),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.string(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RolloutContentArraySchema = z.array(RolloutContentItemSchema);
|
||||||
|
|
||||||
|
function extractContentTextByType(content: unknown, itemType: string): string {
|
||||||
|
const parsed = RolloutContentArraySchema.safeParse(content);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return parsed.data
|
||||||
|
.filter((item) => item.type === itemType)
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const RolloutResponseMessagePayloadSchema = z.object({
|
||||||
|
type: z.literal("message"),
|
||||||
|
role: z.enum(["user", "assistant"]).optional(),
|
||||||
|
content: z.unknown().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutResponseReasoningPayloadSchema = z.object({
|
||||||
|
type: z.literal("reasoning"),
|
||||||
|
content: z.unknown().optional(),
|
||||||
|
summary: z.array(z.object({ text: z.string().optional() })).optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutResponseFunctionCallPayloadSchema = z.object({
|
||||||
|
type: z.literal("function_call"),
|
||||||
|
name: z.string().optional(),
|
||||||
|
call_id: z.string().optional(),
|
||||||
|
arguments: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutResponseCustomToolCallPayloadSchema = z.object({
|
||||||
|
type: z.literal("custom_tool_call"),
|
||||||
|
name: z.string().optional(),
|
||||||
|
call_id: z.string().optional(),
|
||||||
|
arguments: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutResponseFunctionCallOutputPayloadSchema = z.object({
|
||||||
|
type: z.literal("function_call_output"),
|
||||||
|
call_id: z.string().optional(),
|
||||||
|
output: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutEventAgentReasoningPayloadSchema = z.object({
|
||||||
|
type: z.literal("agent_reasoning"),
|
||||||
|
text: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutEventAgentMessagePayloadSchema = z.object({
|
||||||
|
type: z.literal("agent_message"),
|
||||||
|
message: z
|
||||||
|
.union([
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
role: z.string().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutEventUserMessagePayloadSchema = z.object({
|
||||||
|
type: z.literal("user_message"),
|
||||||
|
message: z
|
||||||
|
.union([
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
role: z.string().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RolloutResponseReasoningPayload = z.infer<
|
||||||
|
typeof RolloutResponseReasoningPayloadSchema
|
||||||
|
>;
|
||||||
|
type ParsedRolloutRecord =
|
||||||
|
| { kind: "timeline"; item: AgentTimelineItem }
|
||||||
|
| { kind: "call"; name: string; callId?: string; input?: unknown }
|
||||||
|
| { kind: "output"; callId: string; output: string }
|
||||||
|
| { kind: "ignore" };
|
||||||
|
|
||||||
|
const RolloutMessageContentSchema = z
|
||||||
|
.union([
|
||||||
|
z.string().transform((content) => content.trim()),
|
||||||
|
z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
text: z.string().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
).transform((content) =>
|
||||||
|
content
|
||||||
|
.map((block) => block.text ?? block.message ?? "")
|
||||||
|
.map((text) => text.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractMessageText(content: unknown): string {
|
||||||
|
const parsed = RolloutMessageContentSchema.safeParse(content);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RolloutEventMessageTextSchema = z
|
||||||
|
.union([
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
message: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.transform((message) => message.message ?? message.text ?? ""),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractEventMessageText(message: unknown): string {
|
||||||
|
const parsed = RolloutEventMessageTextSchema.safeParse(message);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSyntheticRolloutUserMessage(text: string): boolean {
|
||||||
|
const normalized = text.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lower = normalized.toLowerCase();
|
||||||
|
if (
|
||||||
|
lower.startsWith("# agents.md instructions for") &&
|
||||||
|
lower.includes("<instructions>")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (lower.startsWith("<environment_context>")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractReasoningText(payload: RolloutResponseReasoningPayload): string {
|
||||||
|
if (Array.isArray(payload.summary)) {
|
||||||
|
const text = payload.summary
|
||||||
|
.map((item) => item.text ?? "")
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const contentText = extractContentTextByType(payload.content, "reasoning_text");
|
||||||
|
if (contentText) {
|
||||||
|
return contentText;
|
||||||
|
}
|
||||||
|
if (typeof payload.text === "string") {
|
||||||
|
return payload.text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonLikeString(value: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FunctionCallInputNormalizationSchema = z
|
||||||
|
.union([
|
||||||
|
z.object({ cmd: z.string() }).transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
|
||||||
|
z
|
||||||
|
.object({ command: z.array(z.string()) })
|
||||||
|
.transform((input) => ({ name: "Bash", input: { command: input.command[2] ?? "" } })),
|
||||||
|
z.unknown().transform((input) => ({ name: "unknown", input })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RolloutResponseRecordSchema = z
|
||||||
|
.union([
|
||||||
|
RolloutResponseMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||||
|
const text = extractMessageText(payload.content);
|
||||||
|
const itemType = payload.role === "assistant" ? "assistant_message" : "user_message";
|
||||||
|
const shouldEmit = text.length > 0 && (itemType !== "user_message" || !isSyntheticRolloutUserMessage(text));
|
||||||
|
return shouldEmit
|
||||||
|
? { kind: "timeline", item: { type: itemType, text } }
|
||||||
|
: { kind: "ignore" };
|
||||||
|
}),
|
||||||
|
RolloutResponseReasoningPayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||||
|
const text = extractReasoningText(payload);
|
||||||
|
return text.length > 0
|
||||||
|
? { kind: "timeline", item: { type: "reasoning", text } }
|
||||||
|
: { kind: "ignore" };
|
||||||
|
}),
|
||||||
|
z
|
||||||
|
.union([
|
||||||
|
RolloutResponseFunctionCallPayloadSchema,
|
||||||
|
RolloutResponseCustomToolCallPayloadSchema,
|
||||||
|
])
|
||||||
|
.transform((payload): ParsedRolloutRecord => {
|
||||||
|
const rawName = payload.name ?? "unknown";
|
||||||
|
const parsedArguments = payload.arguments ? parseJsonLikeString(payload.arguments) : undefined;
|
||||||
|
const normalized =
|
||||||
|
rawName === "exec_command" || rawName === "shell"
|
||||||
|
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
|
||||||
|
: { name: rawName, input: parsedArguments };
|
||||||
|
const skip = rawName === "write_stdin";
|
||||||
|
return skip
|
||||||
|
? { kind: "ignore" }
|
||||||
|
: {
|
||||||
|
kind: "call",
|
||||||
|
name: normalized.name,
|
||||||
|
callId: payload.call_id,
|
||||||
|
input: normalized.input,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
RolloutResponseFunctionCallOutputPayloadSchema.transform(
|
||||||
|
(payload): ParsedRolloutRecord =>
|
||||||
|
payload.call_id && payload.output
|
||||||
|
? { kind: "output", callId: payload.call_id, output: payload.output }
|
||||||
|
: { kind: "ignore" }
|
||||||
|
),
|
||||||
|
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RolloutEventRecordSchema = z
|
||||||
|
.union([
|
||||||
|
RolloutEventAgentReasoningPayloadSchema.transform(
|
||||||
|
(payload): ParsedRolloutRecord =>
|
||||||
|
payload.text
|
||||||
|
? { kind: "timeline", item: { type: "reasoning", text: payload.text } }
|
||||||
|
: { kind: "ignore" }
|
||||||
|
),
|
||||||
|
RolloutEventAgentMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||||
|
const text = extractEventMessageText(payload.message);
|
||||||
|
return text.length > 0
|
||||||
|
? { kind: "timeline", item: { type: "assistant_message", text } }
|
||||||
|
: { kind: "ignore" };
|
||||||
|
}),
|
||||||
|
RolloutEventUserMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||||
|
const text = extractEventMessageText(payload.message);
|
||||||
|
const shouldEmit = text.length > 0 && !isSyntheticRolloutUserMessage(text);
|
||||||
|
return shouldEmit
|
||||||
|
? { kind: "timeline", item: { type: "user_message", text } }
|
||||||
|
: { kind: "ignore" };
|
||||||
|
}),
|
||||||
|
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RolloutRecordSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.enum(["response_item", "event_msg"]),
|
||||||
|
payload: z.unknown().optional(),
|
||||||
|
item: z.unknown().optional(),
|
||||||
|
msg: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
.transform((entry) =>
|
||||||
|
entry.type === "response_item"
|
||||||
|
? RolloutResponseRecordSchema.parse(entry.payload ?? entry.item)
|
||||||
|
: RolloutEventRecordSchema.parse(entry.payload ?? entry.msg)
|
||||||
|
);
|
||||||
|
|
||||||
|
function parseJsonRolloutTimeline(
|
||||||
|
parsed: unknown
|
||||||
|
): AgentTimelineItem[] | null {
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const items = (parsed as { items?: unknown }).items;
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const timeline: AgentTimelineItem[] = [];
|
||||||
|
for (const entry of items) {
|
||||||
|
if (!entry || typeof entry !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const messagePayloadResult =
|
||||||
|
RolloutResponseMessagePayloadSchema.safeParse(entry);
|
||||||
|
if (messagePayloadResult.success) {
|
||||||
|
const payload = messagePayloadResult.data;
|
||||||
|
const text = extractMessageText(payload.content);
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (payload.role === "assistant") {
|
||||||
|
timeline.push({ type: "assistant_message", text });
|
||||||
|
} else if (payload.role === "user") {
|
||||||
|
if (!isSyntheticRolloutUserMessage(text)) {
|
||||||
|
timeline.push({ type: "user_message", text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasoningPayloadResult =
|
||||||
|
RolloutResponseReasoningPayloadSchema.safeParse(entry);
|
||||||
|
if (reasoningPayloadResult.success) {
|
||||||
|
const text = extractReasoningText(reasoningPayloadResult.data);
|
||||||
|
if (text) {
|
||||||
|
timeline.push({ type: "reasoning", text });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return timeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseRolloutFile(
|
||||||
|
filePath: string
|
||||||
|
): Promise<AgentTimelineItem[]> {
|
||||||
|
const content = await fs.readFile(filePath, "utf8");
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
const jsonTimeline = parseJsonRolloutTimeline(parsed);
|
||||||
|
if (jsonTimeline) {
|
||||||
|
return jsonTimeline;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to JSONL parsing.
|
||||||
|
}
|
||||||
|
const lines = trimmed
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const parsedRecords = lines
|
||||||
|
.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((record): record is unknown => record !== null)
|
||||||
|
.map((record) => RolloutRecordSchema.safeParse(record))
|
||||||
|
.filter((result): result is { success: true; data: ParsedRolloutRecord } => result.success)
|
||||||
|
.map((result) => result.data);
|
||||||
|
|
||||||
|
const outputsByCallId = parsedRecords
|
||||||
|
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
|
||||||
|
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, string>());
|
||||||
|
|
||||||
|
return parsedRecords.flatMap((record): AgentTimelineItem[] =>
|
||||||
|
record.kind === "timeline"
|
||||||
|
? [record.item]
|
||||||
|
: record.kind === "call"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
name: record.name,
|
||||||
|
callId: record.callId,
|
||||||
|
status: "completed",
|
||||||
|
input: record.input,
|
||||||
|
output: record.callId ? outputsByCallId.get(record.callId) : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodexPersistedTimelineOptions = {
|
||||||
|
sessionRoot?: string | null;
|
||||||
|
rolloutPath?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadCodexPersistedTimeline(
|
||||||
|
sessionId: string,
|
||||||
|
options?: CodexPersistedTimelineOptions,
|
||||||
|
logger?: Logger
|
||||||
|
): Promise<AgentTimelineItem[]> {
|
||||||
|
const rolloutPath = options?.rolloutPath ?? null;
|
||||||
|
if (rolloutPath) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(rolloutPath);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
const timeline = await parseRolloutFile(rolloutPath);
|
||||||
|
if (timeline.length > 0) {
|
||||||
|
return timeline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to session root scan.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const preferredRoot = options?.sessionRoot ?? resolveCodexSessionRoot();
|
||||||
|
const fallbackRoot = resolveCodexSessionRoot();
|
||||||
|
let rolloutFile: string | null = null;
|
||||||
|
|
||||||
|
if (preferredRoot) {
|
||||||
|
rolloutFile = await findRolloutFile(sessionId, preferredRoot);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!rolloutFile &&
|
||||||
|
fallbackRoot &&
|
||||||
|
fallbackRoot !== preferredRoot
|
||||||
|
) {
|
||||||
|
rolloutFile = await findRolloutFile(sessionId, fallbackRoot);
|
||||||
|
}
|
||||||
|
if (!rolloutFile) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await parseRolloutFile(rolloutFile);
|
||||||
|
} catch (error) {
|
||||||
|
logger?.warn(
|
||||||
|
{ err: error, sessionId },
|
||||||
|
"Failed to load persisted timeline"
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const TOOL_TOKEN_REGEX = /[a-z0-9]+/g;
|
||||||
|
const NAMESPACE_SEPARATOR_REGEX = /[.:/]|__/;
|
||||||
|
|
||||||
|
export function normalizeToolName(name: string): string {
|
||||||
|
return name.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenizeToolName(name: string): string[] {
|
||||||
|
const normalized = normalizeToolName(name);
|
||||||
|
return normalized.match(TOOL_TOKEN_REGEX) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolLeafName(name: string): string | null {
|
||||||
|
const tokens = tokenizeToolName(name);
|
||||||
|
return tokens.length > 0 ? tokens[tokens.length - 1]! : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSpeakToolName(name: string): boolean {
|
||||||
|
return getToolLeafName(name) === "speak";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLikelyNamespacedToolName(name: string): boolean {
|
||||||
|
return NAMESPACE_SEPARATOR_REGEX.test(normalizeToolName(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLikelyExternalToolName(name: string): boolean {
|
||||||
|
const normalized = normalizeToolName(name);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isSpeakToolName(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isLikelyNamespacedToolName(normalized);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
} from "../agent/agent-sdk-types.js";
|
} from "../agent/agent-sdk-types.js";
|
||||||
import type { AgentPermissionRequest, AgentPermissionResponse } from "../agent/agent-sdk-types.js";
|
import type { AgentPermissionRequest, AgentPermissionResponse } from "../agent/agent-sdk-types.js";
|
||||||
|
import { isLikelyExternalToolName } from "../agent/tool-name-normalization.js";
|
||||||
|
|
||||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
@@ -656,7 +657,7 @@ class FakeAgentSession implements AgentSession {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolName.startsWith("mcp__")) {
|
if (isLikelyExternalToolName(toolName)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,20 @@ function buildRequest(partial: Partial<AgentPermissionRequest>): AgentPermission
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("isVoicePermissionAllowed", () => {
|
describe("isVoicePermissionAllowed", () => {
|
||||||
test("allows speak tool", () => {
|
test("allows direct speak tool names across provider conventions", () => {
|
||||||
const result = isVoicePermissionAllowed(
|
const result = isVoicePermissionAllowed(
|
||||||
buildRequest({ name: "speak" })
|
buildRequest({ name: "speak" })
|
||||||
);
|
);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
expect(
|
||||||
|
isVoicePermissionAllowed(buildRequest({ name: "paseo_voice.speak" }))
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isVoicePermissionAllowed(buildRequest({ name: "mcp__paseo_voice__speak" }))
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("denies explicit MCP/paseo tool names", () => {
|
test("denies non-speak tool names", () => {
|
||||||
expect(
|
expect(
|
||||||
isVoicePermissionAllowed(buildRequest({ name: "mcp__paseo__create_agent" }))
|
isVoicePermissionAllowed(buildRequest({ name: "mcp__paseo__create_agent" }))
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -37,12 +43,12 @@ describe("isVoicePermissionAllowed", () => {
|
|||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows codextool only when metadata references speak", () => {
|
test("allows wrapper tools when metadata references speak", () => {
|
||||||
const allowed = isVoicePermissionAllowed(
|
const allowed = isVoicePermissionAllowed(
|
||||||
buildRequest({
|
buildRequest({
|
||||||
name: "codextool",
|
name: "codextool",
|
||||||
metadata: {
|
metadata: {
|
||||||
questions: [{ question: "Allow codextool to call speak for user feedback?" }],
|
questions: [{ question: "Allow codextool to call paseo_voice.speak for user feedback?" }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import type { AgentPermissionRequest } from "./agent/agent-sdk-types.js";
|
import type { AgentPermissionRequest } from "./agent/agent-sdk-types.js";
|
||||||
|
import { isSpeakToolName } from "./agent/tool-name-normalization.js";
|
||||||
|
|
||||||
const ALLOWED_TOKEN_SET = ["speak"];
|
const SPEAK_TOKEN_SET = ["speak"];
|
||||||
const DENIED_TOKEN_SET = [
|
const DENIED_TOKEN_SET = [
|
||||||
"mcp",
|
|
||||||
"paseo",
|
|
||||||
"bash",
|
"bash",
|
||||||
"shell",
|
"shell",
|
||||||
"terminal",
|
"terminal",
|
||||||
"command",
|
"apply_patch",
|
||||||
"execute",
|
|
||||||
"edit",
|
"edit",
|
||||||
"write",
|
"read_file",
|
||||||
"read",
|
"write_file",
|
||||||
"fetch",
|
"delete_file",
|
||||||
"http",
|
"web_search",
|
||||||
"web",
|
"fetch_url",
|
||||||
|
"create_agent",
|
||||||
|
"list_agents",
|
||||||
|
"kill_agent",
|
||||||
|
"wait_for_agent",
|
||||||
];
|
];
|
||||||
|
|
||||||
function containsAny(text: string, tokens: readonly string[]): boolean {
|
function containsAny(text: string, tokens: readonly string[]): boolean {
|
||||||
@@ -40,15 +42,14 @@ export function isVoicePermissionAllowed(request: AgentPermissionRequest): boole
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedName === "speak") {
|
if (isSpeakToolName(normalizedName)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedName !== "codextool") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadataText = stringifyMetadata({
|
const metadataText = stringifyMetadata({
|
||||||
|
name: request.name,
|
||||||
|
title: request.title ?? null,
|
||||||
|
description: request.description ?? null,
|
||||||
metadata: request.metadata ?? null,
|
metadata: request.metadata ?? null,
|
||||||
input: request.input ?? null,
|
input: request.input ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user