chore: checkpoint
This commit is contained in:
@@ -0,0 +1,172 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
import {
|
||||||
|
generateKeyPair,
|
||||||
|
exportPublicKey,
|
||||||
|
importPublicKey,
|
||||||
|
deriveSharedKey,
|
||||||
|
encrypt,
|
||||||
|
decrypt,
|
||||||
|
} from "./crypto.js";
|
||||||
|
|
||||||
|
const RELAY_BASE_URL = "wss://relay.paseo.sh";
|
||||||
|
|
||||||
|
async function withRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: { retries: number; delayMs: number }
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt <= options.retries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (attempt < options.retries) {
|
||||||
|
await new Promise((r) => setTimeout(r, options.delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Live relay (relay.paseo.sh) E2E", () => {
|
||||||
|
it("bridges encrypted traffic end-to-end", { timeout: 45_000 }, async () => {
|
||||||
|
await withRetry(
|
||||||
|
async () => {
|
||||||
|
const sessionId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
const serverUrl = `${RELAY_BASE_URL}/ws?session=${encodeURIComponent(sessionId)}&role=server`;
|
||||||
|
const clientUrl = `${RELAY_BASE_URL}/ws?session=${encodeURIComponent(sessionId)}&role=client`;
|
||||||
|
|
||||||
|
// === Key setup ===
|
||||||
|
const daemonKeyPair = await generateKeyPair();
|
||||||
|
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||||
|
|
||||||
|
const clientKeyPair = await generateKeyPair();
|
||||||
|
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
||||||
|
|
||||||
|
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
|
||||||
|
const clientSharedKey = await deriveSharedKey(
|
||||||
|
clientKeyPair.privateKey,
|
||||||
|
daemonPubKeyOnClient
|
||||||
|
);
|
||||||
|
|
||||||
|
// === Connect ===
|
||||||
|
const daemonWs = new WebSocket(serverUrl);
|
||||||
|
const clientWs = new WebSocket(clientUrl);
|
||||||
|
|
||||||
|
const waitOpen = (ws: WebSocket, label: string) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => reject(new Error(`Timed out opening ${label} websocket`)),
|
||||||
|
10_000
|
||||||
|
);
|
||||||
|
ws.once("open", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.once("error", (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([waitOpen(daemonWs, "server"), waitOpen(clientWs, "client")]);
|
||||||
|
|
||||||
|
// === Handshake ===
|
||||||
|
// Client sends hello with its public key (not encrypted).
|
||||||
|
clientWs.send(JSON.stringify({ type: "hello", key: clientPubKeyB64 }));
|
||||||
|
|
||||||
|
const daemonReceivedHello = await new Promise<string>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => reject(new Error("Timed out waiting for hello")),
|
||||||
|
10_000
|
||||||
|
);
|
||||||
|
daemonWs.once("message", (data) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(data.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hello = JSON.parse(daemonReceivedHello) as {
|
||||||
|
type: string;
|
||||||
|
key?: string;
|
||||||
|
};
|
||||||
|
expect(hello.type).toBe("hello");
|
||||||
|
expect(typeof hello.key).toBe("string");
|
||||||
|
|
||||||
|
const clientPubKeyOnDaemon = await importPublicKey(hello.key!);
|
||||||
|
const daemonSharedKey = await deriveSharedKey(
|
||||||
|
daemonKeyPair.privateKey,
|
||||||
|
clientPubKeyOnDaemon
|
||||||
|
);
|
||||||
|
|
||||||
|
// === Encrypted exchange ===
|
||||||
|
const plaintextFromClient = "hello-from-client";
|
||||||
|
const ciphertextFromClient = await encrypt(
|
||||||
|
clientSharedKey,
|
||||||
|
plaintextFromClient
|
||||||
|
);
|
||||||
|
clientWs.send(Buffer.from(ciphertextFromClient));
|
||||||
|
|
||||||
|
const daemonReceivedCiphertext = await new Promise<Buffer>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => reject(new Error("Timed out waiting for encrypted message")),
|
||||||
|
10_000
|
||||||
|
);
|
||||||
|
daemonWs.once("message", (data) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(data as Buffer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptedOnDaemon = await decrypt(
|
||||||
|
daemonSharedKey,
|
||||||
|
daemonReceivedCiphertext.buffer.slice(
|
||||||
|
daemonReceivedCiphertext.byteOffset,
|
||||||
|
daemonReceivedCiphertext.byteOffset +
|
||||||
|
daemonReceivedCiphertext.byteLength
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(decryptedOnDaemon).toBe(plaintextFromClient);
|
||||||
|
|
||||||
|
const plaintextFromDaemon = "hello-from-daemon";
|
||||||
|
const ciphertextFromDaemon = await encrypt(
|
||||||
|
daemonSharedKey,
|
||||||
|
plaintextFromDaemon
|
||||||
|
);
|
||||||
|
daemonWs.send(Buffer.from(ciphertextFromDaemon));
|
||||||
|
|
||||||
|
const clientReceivedCiphertext = await new Promise<Buffer>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => reject(new Error("Timed out waiting for encrypted response")),
|
||||||
|
10_000
|
||||||
|
);
|
||||||
|
clientWs.once("message", (data) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(data as Buffer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptedOnClient = await decrypt(
|
||||||
|
clientSharedKey,
|
||||||
|
clientReceivedCiphertext.buffer.slice(
|
||||||
|
clientReceivedCiphertext.byteOffset,
|
||||||
|
clientReceivedCiphertext.byteOffset +
|
||||||
|
clientReceivedCiphertext.byteLength
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(decryptedOnClient).toBe(plaintextFromDaemon);
|
||||||
|
} finally {
|
||||||
|
daemonWs.close();
|
||||||
|
clientWs.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ retries: 2, delayMs: 250 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name = "paseo-relay"
|
||||||
|
account_id = "10ed39a1dbf316e30abd0c409bed40d6"
|
||||||
|
main = "src/cloudflare-adapter.ts"
|
||||||
|
compatibility_date = "2024-12-01"
|
||||||
|
|
||||||
|
routes = [
|
||||||
|
{ pattern = "relay.paseo.sh", custom_domain = true }
|
||||||
|
]
|
||||||
|
|
||||||
|
[observability]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[[durable_objects.bindings]]
|
||||||
|
name = "RELAY"
|
||||||
|
class_name = "RelayDurableObject"
|
||||||
|
|
||||||
|
[[migrations]]
|
||||||
|
tag = "v1"
|
||||||
|
new_sqlite_classes = ["RelayDurableObject"]
|
||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
GitRepoInfoResponse,
|
GitRepoInfoResponse,
|
||||||
HighlightedDiffResponse,
|
HighlightedDiffResponse,
|
||||||
ListCommandsResponse,
|
ListCommandsResponse,
|
||||||
|
ExecuteCommandResponse,
|
||||||
ListConversationsResponseMessage,
|
ListConversationsResponseMessage,
|
||||||
ListProviderModelsResponseMessage,
|
ListProviderModelsResponseMessage,
|
||||||
ListTerminalsResponse,
|
ListTerminalsResponse,
|
||||||
@@ -171,6 +172,7 @@ type FileExplorerPayload = FileExplorerResponse["payload"];
|
|||||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||||
|
type ExecuteCommandPayload = ExecuteCommandResponse["payload"];
|
||||||
type TranscriptionResultPayload = TranscriptionResultMessage["payload"];
|
type TranscriptionResultPayload = TranscriptionResultMessage["payload"];
|
||||||
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
@@ -1150,6 +1152,37 @@ export class DaemonClientV2 {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async executeCommand(
|
||||||
|
agentId: string,
|
||||||
|
commandName: string,
|
||||||
|
args?: string,
|
||||||
|
requestId?: string
|
||||||
|
): Promise<ExecuteCommandPayload> {
|
||||||
|
const resolvedRequestId = this.createRequestId(requestId);
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "execute_command_request",
|
||||||
|
agentId,
|
||||||
|
commandName,
|
||||||
|
args,
|
||||||
|
requestId: resolvedRequestId,
|
||||||
|
});
|
||||||
|
const response = this.waitFor(
|
||||||
|
(msg) => {
|
||||||
|
if (msg.type !== "execute_command_response") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (msg.payload.requestId !== resolvedRequestId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return msg.payload;
|
||||||
|
},
|
||||||
|
30000,
|
||||||
|
{ skipQueue: true }
|
||||||
|
);
|
||||||
|
this.sendSessionMessage(message);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Permissions
|
// Permissions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
type RunResult = { code: number; stdout: string; stderr: string };
|
||||||
|
|
||||||
|
function isCodexAvailable(): boolean {
|
||||||
|
try {
|
||||||
|
execFileSync("codex", ["--version"], { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
opts: { cwd: string; env?: NodeJS.ProcessEnv; timeoutMs: number },
|
||||||
|
): Promise<RunResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(cmd, args, {
|
||||||
|
cwd: opts.cwd,
|
||||||
|
env: opts.env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stdoutChunks: Buffer[] = [];
|
||||||
|
const stderrChunks: Buffer[] = [];
|
||||||
|
child.stdout.on("data", (d) => stdoutChunks.push(d));
|
||||||
|
child.stderr.on("data", (d) => stderrChunks.push(d));
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({
|
||||||
|
code: code ?? 1,
|
||||||
|
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
||||||
|
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Codex CLI full-access sandbox", () => {
|
||||||
|
test("can listen on unix socket (no EPERM)", { timeout: 240_000 }, async (ctx) => {
|
||||||
|
if (process.env.PASEO_CODEX_CLI_E2E !== "1") {
|
||||||
|
ctx.skip();
|
||||||
|
}
|
||||||
|
if (!isCodexAvailable()) {
|
||||||
|
ctx.skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(testDir, "../../../../../..");
|
||||||
|
|
||||||
|
const prompt =
|
||||||
|
"Run exactly this shell command and then stop:\n" +
|
||||||
|
"bash -lc 'node scripts/repro-ipc-listen.js; echo EXIT_CODE:$?'\n" +
|
||||||
|
"Reply with only the raw command stdout/stderr (no extra text).";
|
||||||
|
|
||||||
|
const result = await run(
|
||||||
|
"codex",
|
||||||
|
[
|
||||||
|
"-a",
|
||||||
|
"never",
|
||||||
|
"exec",
|
||||||
|
"--dangerously-bypass-approvals-and-sandbox",
|
||||||
|
"--color",
|
||||||
|
"never",
|
||||||
|
"-C",
|
||||||
|
repoRoot,
|
||||||
|
prompt,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: process.env,
|
||||||
|
timeoutMs: 180_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = `${result.stdout}\n${result.stderr}`;
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(`codex exited ${result.code}\n--- output ---\n${output}`);
|
||||||
|
}
|
||||||
|
expect(output).toMatch(/\bsandbox:\s*danger-full-access\b/);
|
||||||
|
expect(output).toMatch(/\bLISTENING\b/);
|
||||||
|
expect(output).toMatch(/\bEXIT_CODE:0\b/);
|
||||||
|
expect(output).not.toMatch(/\bEPERM\b/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../../test-utils/index.js";
|
||||||
|
import { getFullAccessConfig } from "../../daemon-e2e/agent-configs.js";
|
||||||
|
|
||||||
|
function tmpDir(prefix: string): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("codex agent commands E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
test("lists available slash commands for a codex agent", async () => {
|
||||||
|
const prevCodexHome = process.env.CODEX_HOME;
|
||||||
|
const codexHome = tmpDir("codex-home-");
|
||||||
|
const promptsDir = path.join(codexHome, "prompts");
|
||||||
|
const skillsDir = path.join(codexHome, "skills");
|
||||||
|
mkdirSync(promptsDir, { recursive: true });
|
||||||
|
mkdirSync(skillsDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path.join(promptsDir, "hello.md"),
|
||||||
|
["---", "description: Test prompt", "argument-hint: NAME=<name>", "---", "", "Say hello to $NAME and then output exactly PASEO_OK.", ""].join("\n"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
mkdirSync(path.join(skillsDir, "my-skill"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path.join(skillsDir, "my-skill", "SKILL.md"),
|
||||||
|
["---", "name: my-skill", "description: Test skill", "user-invocable: true", "---", "", "When invoked, respond with exactly PASEO_SKILL_OK.", ""].join("\n"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
process.env.CODEX_HOME = codexHome;
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
...getFullAccessConfig("codex"),
|
||||||
|
cwd: "/tmp",
|
||||||
|
title: "Codex Commands Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.provider).toBe("codex");
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
const result = await ctx.client.listCommands(agent.id);
|
||||||
|
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.commands.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const cmd of result.commands) {
|
||||||
|
expect(cmd.name).toBeTruthy();
|
||||||
|
expect(typeof cmd.description).toBe("string");
|
||||||
|
expect(typeof cmd.argumentHint).toBe("string");
|
||||||
|
expect(cmd.name.startsWith("/")).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = result.commands.map((c) => c.name);
|
||||||
|
expect(names).toContain("my-skill");
|
||||||
|
expect(names).toContain("prompts:hello");
|
||||||
|
|
||||||
|
if (prevCodexHome === undefined) {
|
||||||
|
delete process.env.CODEX_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.CODEX_HOME = prevCodexHome;
|
||||||
|
}
|
||||||
|
rmSync(codexHome, { recursive: true, force: true });
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
test("executes a custom prompt command (prompts:*)", async () => {
|
||||||
|
const codexHome = process.env.CODEX_HOME ?? path.join(process.env.HOME ?? "/tmp", ".codex");
|
||||||
|
const authPath = path.join(codexHome, "auth.json");
|
||||||
|
if (!existsSync(authPath) && !process.env.OPENAI_API_KEY) {
|
||||||
|
// Skip when Codex isn't authenticated in this environment.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptsDir = path.join(codexHome, "prompts");
|
||||||
|
mkdirSync(promptsDir, { recursive: true });
|
||||||
|
const promptPath = path.join(promptsDir, "paseo-test-sayok.md");
|
||||||
|
writeFileSync(
|
||||||
|
promptPath,
|
||||||
|
["---", "description: Say OK", "argument-hint: NAME=<name>", "---", "", "Output exactly: PASEO_OK $NAME", ""].join("\n"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
...getFullAccessConfig("codex"),
|
||||||
|
cwd: "/tmp",
|
||||||
|
title: "Codex Prompt Execute Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await ctx.client.executeCommand(
|
||||||
|
agent.id,
|
||||||
|
"prompts:paseo-test-sayok",
|
||||||
|
"NAME=world"
|
||||||
|
);
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.result?.text).toContain("PASEO_OK");
|
||||||
|
|
||||||
|
rmSync(promptPath, { force: true });
|
||||||
|
}, 180000);
|
||||||
|
|
||||||
|
test("returns error for non-existent agent", async () => {
|
||||||
|
const result = await ctx.client.listCommands("non-existent-agent-id");
|
||||||
|
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
expect(result.error).toContain("Agent not found");
|
||||||
|
expect(result.commands).toEqual([]);
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import type { Logger } from "pino";
|
|||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentCommandResult,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
@@ -27,6 +28,7 @@ import type {
|
|||||||
AgentPromptInput,
|
AgentPromptInput,
|
||||||
AgentRunOptions,
|
AgentRunOptions,
|
||||||
AgentRunResult,
|
AgentRunResult,
|
||||||
|
AgentSlashCommand,
|
||||||
AgentSession,
|
AgentSession,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
@@ -1932,6 +1934,249 @@ function normalizeCommand(command: Command): string {
|
|||||||
return typeof command === "string" ? command : command.join(" ");
|
return typeof command === "string" ? command : command.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCodexHomeDir(): string {
|
||||||
|
return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeCommandArgs(args: string): string[] {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let quote: "'" | "\"" | null = null;
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const ch = args[i]!;
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) {
|
||||||
|
quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\\" && i + 1 < args.length) {
|
||||||
|
const next = args[i + 1]!;
|
||||||
|
if (next === quote || next === "\\" || next === "n" || next === "t") {
|
||||||
|
i += 1;
|
||||||
|
current += next === "n" ? "\n" : next === "t" ? "\t" : next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === "'" || ch === "\"") {
|
||||||
|
quote = ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\s/.test(ch)) {
|
||||||
|
if (current) {
|
||||||
|
tokens.push(current);
|
||||||
|
current = "";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
tokens.push(current);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontMatter(markdown: string): {
|
||||||
|
frontMatter: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
} {
|
||||||
|
const lines = markdown.split("\n");
|
||||||
|
if (lines[0]?.trim() !== "---") {
|
||||||
|
return { frontMatter: {}, body: markdown };
|
||||||
|
}
|
||||||
|
let end = -1;
|
||||||
|
for (let i = 1; i < lines.length; i += 1) {
|
||||||
|
if (lines[i]?.trim() === "---") {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end === -1) {
|
||||||
|
return { frontMatter: {}, body: markdown };
|
||||||
|
}
|
||||||
|
const metaLines = lines.slice(1, end);
|
||||||
|
const body = lines.slice(end + 1).join("\n");
|
||||||
|
const frontMatter: Record<string, string> = {};
|
||||||
|
for (const line of metaLines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const idx = trimmed.indexOf(":");
|
||||||
|
if (idx <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = trimmed.slice(0, idx).trim();
|
||||||
|
let value = trimmed.slice(idx + 1).trim();
|
||||||
|
value = value.replace(/^['"]/, "").replace(/['"]$/, "");
|
||||||
|
if (key && value) {
|
||||||
|
frontMatter[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { frontMatter, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
|
||||||
|
const codexHome = resolveCodexHomeDir();
|
||||||
|
const promptsDir = path.join(codexHome, "prompts");
|
||||||
|
let entries: Dirent[];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(promptsDir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands: AgentSlashCommand[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.name.endsWith(".md")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = entry.name.slice(0, -".md".length);
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fullPath = path.join(promptsDir, entry.name);
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(fullPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parseFrontMatter(content);
|
||||||
|
const description =
|
||||||
|
parsed.frontMatter["description"] ?? "Custom prompt";
|
||||||
|
const argumentHint =
|
||||||
|
parsed.frontMatter["argument-hint"] ??
|
||||||
|
parsed.frontMatter["argument_hint"] ??
|
||||||
|
"";
|
||||||
|
commands.push({
|
||||||
|
name: `prompts:${name}`,
|
||||||
|
description,
|
||||||
|
argumentHint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCodexSkills(cwd: string): Promise<AgentSlashCommand[]> {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
candidates.push(path.join(cwd, ".codex", "skills"));
|
||||||
|
|
||||||
|
const repoRoot = (() => {
|
||||||
|
try {
|
||||||
|
const output = execSync("git rev-parse --show-toplevel", {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
const trimmed = output.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (repoRoot) {
|
||||||
|
candidates.push(path.join(path.dirname(cwd), ".codex", "skills"));
|
||||||
|
candidates.push(path.join(repoRoot, ".codex", "skills"));
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(path.join(resolveCodexHomeDir(), "skills"));
|
||||||
|
|
||||||
|
const commandsByName = new Map<string, AgentSlashCommand>();
|
||||||
|
|
||||||
|
for (const dir of candidates) {
|
||||||
|
let entries: Dirent[];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const skillDir = path.join(dir, entry.name);
|
||||||
|
const skillPath = path.join(skillDir, "SKILL.md");
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(skillPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { frontMatter } = parseFrontMatter(content);
|
||||||
|
const name = frontMatter["name"];
|
||||||
|
const description = frontMatter["description"];
|
||||||
|
if (!name || !description) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!commandsByName.has(name)) {
|
||||||
|
commandsByName.set(name, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
argumentHint: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(commandsByName.values()).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandCodexCustomPrompt(template: string, args: string | undefined): string {
|
||||||
|
const trimmedArgs = args ? args.trim() : "";
|
||||||
|
const tokens = trimmedArgs ? tokenizeCommandArgs(trimmedArgs) : [];
|
||||||
|
const named: Record<string, string> = {};
|
||||||
|
const positional: string[] = [];
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
const idx = token.indexOf("=");
|
||||||
|
if (idx > 0) {
|
||||||
|
const key = token.slice(0, idx);
|
||||||
|
const value = token.slice(idx + 1);
|
||||||
|
if (key) {
|
||||||
|
named[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
positional.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dollarPlaceholder = "__CODEX_DOLLAR_PLACEHOLDER__";
|
||||||
|
let out = template.split("$$").join(dollarPlaceholder);
|
||||||
|
|
||||||
|
out = out.split("$ARGUMENTS").join(trimmedArgs);
|
||||||
|
|
||||||
|
for (let i = 1; i <= 9; i += 1) {
|
||||||
|
const value = positional[i - 1] ?? "";
|
||||||
|
out = out.split(`$${i}`).join(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const namedKeys = Object.keys(named).sort((a, b) => b.length - a.length);
|
||||||
|
for (const key of namedKeys) {
|
||||||
|
const value = named[key] ?? "";
|
||||||
|
const re = new RegExp(`\\$${escapeRegExp(key)}\\b`, "g");
|
||||||
|
out = out.replace(re, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
out = out.split(dollarPlaceholder).join("$");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function extractFileReadFromParsedCmd(parsedCmd: ParsedCmdItem[] | undefined): {
|
function extractFileReadFromParsedCmd(parsedCmd: ParsedCmdItem[] | undefined): {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2598,14 +2843,21 @@ function isUnsupportedChatGptModelError(error: unknown): boolean {
|
|||||||
return message.includes("model is not supported when using Codex with a ChatGPT account");
|
return message.includes("model is not supported when using Codex with a ChatGPT account");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMissingSessionForConversationOrThread(message: string): boolean {
|
||||||
|
return (
|
||||||
|
message.includes("Session not found for conversation_id") ||
|
||||||
|
message.includes("Session not found for thread_id")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isMissingConversationIdError(error: unknown): boolean {
|
function isMissingConversationIdError(error: unknown): boolean {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return message.includes("Session not found for conversation_id");
|
return isMissingSessionForConversationOrThread(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMissingConversationIdResponse(response: unknown): boolean {
|
function isMissingConversationIdResponse(response: unknown): boolean {
|
||||||
const text = extractTextContent(response);
|
const text = extractTextContent(response);
|
||||||
return !!text && text.includes("Session not found for conversation_id");
|
return !!text && isMissingSessionForConversationOrThread(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2775,6 +3027,7 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
private lockConversationId = false;
|
private lockConversationId = false;
|
||||||
private historyPending = false;
|
private historyPending = false;
|
||||||
private persistedHistory: AgentTimelineItem[] = [];
|
private persistedHistory: AgentTimelineItem[] = [];
|
||||||
|
private resumeContextHistory: AgentTimelineItem[] = [];
|
||||||
private pendingHistory: AgentTimelineItem[] = [];
|
private pendingHistory: AgentTimelineItem[] = [];
|
||||||
private turnState: TurnState | null = null;
|
private turnState: TurnState | null = null;
|
||||||
private pendingPatchChanges = new Map<string, PatchFileChange[]>();
|
private pendingPatchChanges = new Map<string, PatchFileChange[]>();
|
||||||
@@ -2898,6 +3151,7 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
}, this.logger);
|
}, this.logger);
|
||||||
if (timeline.length > 0) {
|
if (timeline.length > 0) {
|
||||||
this.persistedHistory = timeline;
|
this.persistedHistory = timeline;
|
||||||
|
this.resumeContextHistory = [...timeline];
|
||||||
this.historyPending = true;
|
this.historyPending = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3268,6 +3522,31 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
this.managedAgentId = agentId;
|
this.managedAgentId = agentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||||
|
const [skills, prompts] = await Promise.all([
|
||||||
|
listCodexSkills(this.config.cwd),
|
||||||
|
listCodexCustomPrompts(),
|
||||||
|
]);
|
||||||
|
return [...skills, ...prompts];
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeCommand(commandName: string, args?: string): Promise<AgentCommandResult> {
|
||||||
|
if (commandName.startsWith("prompts:")) {
|
||||||
|
const promptName = commandName.slice("prompts:".length);
|
||||||
|
const codexHome = resolveCodexHomeDir();
|
||||||
|
const promptPath = path.join(codexHome, "prompts", `${promptName}.md`);
|
||||||
|
const raw = await fs.readFile(promptPath, "utf8");
|
||||||
|
const parsed = parseFrontMatter(raw);
|
||||||
|
const expanded = expandCodexCustomPrompt(parsed.body, args);
|
||||||
|
const result = await this.run(expanded);
|
||||||
|
return { text: result.finalText, timeline: result.timeline, usage: result.usage };
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillPrompt = args ? `$${commandName} ${args}` : `$${commandName}`;
|
||||||
|
const result = await this.run(skillPrompt);
|
||||||
|
return { text: result.finalText, timeline: result.timeline, usage: result.usage };
|
||||||
|
}
|
||||||
|
|
||||||
private async forwardPrompt(
|
private async forwardPrompt(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
_options: AgentRunOptions | undefined,
|
_options: AgentRunOptions | undefined,
|
||||||
@@ -4145,7 +4424,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private buildResumePrompt(prompt: string): string {
|
private buildResumePrompt(prompt: string): string {
|
||||||
const historyLines: string[] = [];
|
const historyLines: string[] = [];
|
||||||
for (const item of this.persistedHistory) {
|
// Use resumeContextHistory instead of persistedHistory because
|
||||||
|
// persistedHistory gets cleared by streamHistory() before the first message is sent
|
||||||
|
for (const item of this.resumeContextHistory) {
|
||||||
if (item.type === "user_message") {
|
if (item.type === "user_message") {
|
||||||
historyLines.push(`User: ${item.text}`);
|
historyLines.push(`User: ${item.text}`);
|
||||||
}
|
}
|
||||||
@@ -4329,6 +4610,14 @@ export class CodexMcpAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __test__ = {
|
||||||
|
tokenizeCommandArgs,
|
||||||
|
parseFrontMatter,
|
||||||
|
expandCodexCustomPrompt,
|
||||||
|
isMissingConversationIdError,
|
||||||
|
isMissingConversationIdResponse,
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Codex model listing helpers
|
// Codex model listing helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { describe, test, expect, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from "fs";
|
||||||
|
import { mkdtemp, rm } from "fs/promises";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
|
||||||
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
|
import type { PersistenceHandle } from "../../shared/messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
const { createServer } = await import("net");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = createServer();
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to acquire port")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.close(() => resolve(address.port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DaemonInstance {
|
||||||
|
daemon: Awaited<ReturnType<typeof createPaseoDaemon>>;
|
||||||
|
client: DaemonClient;
|
||||||
|
port: number;
|
||||||
|
paseoHome: string;
|
||||||
|
staticDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDaemon(options: {
|
||||||
|
paseoHome: string;
|
||||||
|
staticDir?: string;
|
||||||
|
}): Promise<DaemonInstance> {
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
const staticDir = options.staticDir ?? await mkdtemp(path.join(tmpdir(), "paseo-static-"));
|
||||||
|
|
||||||
|
const config: PaseoDaemonConfig = {
|
||||||
|
listen: `127.0.0.1:${port}`,
|
||||||
|
paseoHome: options.paseoHome,
|
||||||
|
corsAllowedOrigins: [],
|
||||||
|
agentMcpRoute: "/mcp/agents",
|
||||||
|
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
|
||||||
|
staticDir,
|
||||||
|
mcpDebug: false,
|
||||||
|
agentClients: {},
|
||||||
|
agentRegistryPath: path.join(options.paseoHome, "agents.json"),
|
||||||
|
agentControlMcp: {
|
||||||
|
url: `http://127.0.0.1:${port}/mcp/agents`,
|
||||||
|
},
|
||||||
|
openai: process.env.OPENAI_API_KEY ? { apiKey: process.env.OPENAI_API_KEY } : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
const daemon = await createPaseoDaemon(config, logger);
|
||||||
|
await daemon.start();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${port}/ws`,
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
daemon,
|
||||||
|
client,
|
||||||
|
port,
|
||||||
|
paseoHome: options.paseoHome,
|
||||||
|
staticDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDaemon(instance: DaemonInstance): Promise<void> {
|
||||||
|
await instance.client.close();
|
||||||
|
await instance.daemon.stop();
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("daemon restart and agent resume", () => {
|
||||||
|
let paseoHome: string | null = null;
|
||||||
|
let staticDir: string | null = null;
|
||||||
|
let cwd: string | null = null;
|
||||||
|
let currentDaemon: DaemonInstance | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (currentDaemon) {
|
||||||
|
await stopDaemon(currentDaemon).catch(() => undefined);
|
||||||
|
currentDaemon = null;
|
||||||
|
}
|
||||||
|
if (paseoHome) {
|
||||||
|
await rm(paseoHome, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
paseoHome = null;
|
||||||
|
}
|
||||||
|
if (staticDir) {
|
||||||
|
await rm(staticDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
staticDir = null;
|
||||||
|
}
|
||||||
|
if (cwd) {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
cwd = null;
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Codex agent survives daemon kill and restart, preserving conversation context",
|
||||||
|
async () => {
|
||||||
|
// Create isolated directories that persist across daemon restarts
|
||||||
|
// NOTE: We use the default CODEX_HOME (~/.codex) for sessions because
|
||||||
|
// Codex CLI needs its config for API authentication
|
||||||
|
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-home-restart-"));
|
||||||
|
staticDir = await mkdtemp(path.join(tmpdir(), "paseo-static-restart-"));
|
||||||
|
cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Use a unique secret that we'll verify after restart
|
||||||
|
const secretPhrase = `DAEMON_RESTART_SECRET_${Date.now()}`;
|
||||||
|
|
||||||
|
// === PHASE 1: Start daemon and create Codex agent with secret ===
|
||||||
|
currentDaemon = await startDaemon({ paseoHome, staticDir });
|
||||||
|
|
||||||
|
const agent = await currentDaemon.client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Daemon Restart Test Agent",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Ask the agent to remember the secret
|
||||||
|
await currentDaemon.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
`Remember this secret phrase: "${secretPhrase}". Just confirm you've remembered it with a short reply.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterRemember = await currentDaemon.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(afterRemember.status).toBe("idle");
|
||||||
|
expect(afterRemember.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify we got a confirmation and capture persistence handle
|
||||||
|
const queue = currentDaemon.client.getMessageQueue();
|
||||||
|
const confirmationMessages: string[] = [];
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
if (item.type === "assistant_message" && item.text) {
|
||||||
|
confirmationMessages.push(item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(confirmationMessages.join("").length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Get persistence handle for resuming after restart
|
||||||
|
expect(afterRemember.persistence).toBeTruthy();
|
||||||
|
const persistence = afterRemember.persistence as PersistenceHandle;
|
||||||
|
expect(persistence.provider).toBe("codex");
|
||||||
|
expect(persistence.sessionId).toBeTruthy();
|
||||||
|
|
||||||
|
// Verify persistence metadata has conversationId
|
||||||
|
const metadata = persistence.metadata as Record<string, unknown>;
|
||||||
|
expect(metadata.conversationId).toBeTruthy();
|
||||||
|
|
||||||
|
// Wait briefly to ensure Codex has flushed session files to disk
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
// === PHASE 2: Kill the daemon (simulating crash/restart) ===
|
||||||
|
await stopDaemon(currentDaemon);
|
||||||
|
currentDaemon = null;
|
||||||
|
|
||||||
|
// Verify agents.json was persisted
|
||||||
|
const agentsJsonPath = path.join(paseoHome, "agents.json");
|
||||||
|
expect(existsSync(agentsJsonPath)).toBe(true);
|
||||||
|
|
||||||
|
// === PHASE 3: Start a NEW daemon with the SAME paseoHome ===
|
||||||
|
currentDaemon = await startDaemon({ paseoHome, staticDir });
|
||||||
|
|
||||||
|
// === PHASE 4: Resume the agent using the persistence handle ===
|
||||||
|
const resumedAgent = await currentDaemon.client.resumeAgent(persistence);
|
||||||
|
|
||||||
|
expect(resumedAgent.id).toBeTruthy();
|
||||||
|
expect(resumedAgent.status).toBe("idle");
|
||||||
|
expect(resumedAgent.provider).toBe("codex");
|
||||||
|
expect(resumedAgent.cwd).toBe(cwd);
|
||||||
|
|
||||||
|
// Wait a moment for history to load
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
|
// === PHASE 5: Ask about the secret to verify conversation context is preserved ===
|
||||||
|
// This is the CRITICAL test: after daemon restart, the model should remember
|
||||||
|
// the secret from the previous conversation. If it doesn't, the resume is broken.
|
||||||
|
currentDaemon.client.clearMessageQueue();
|
||||||
|
await currentDaemon.client.sendMessage(
|
||||||
|
resumedAgent.id,
|
||||||
|
"What was the secret phrase I asked you to remember earlier? Just reply with the exact phrase."
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterMessage = await currentDaemon.client.waitForAgentIdle(resumedAgent.id, 120000);
|
||||||
|
expect(afterMessage.status).toBe("idle");
|
||||||
|
expect(afterMessage.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// === PHASE 6: Verify the agent remembers the secret (proves context is preserved) ===
|
||||||
|
const responseQueue = currentDaemon.client.getMessageQueue();
|
||||||
|
const responseMessages: string[] = [];
|
||||||
|
for (const m of responseQueue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === resumedAgent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
if (item.type === "assistant_message" && item.text) {
|
||||||
|
responseMessages.push(item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fullResponse = responseMessages.join("");
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION: The agent should remember the secret phrase from before daemon restart
|
||||||
|
// This proves conversation context was properly restored via buildResumePrompt
|
||||||
|
expect(fullResponse).toContain(secretPhrase);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await currentDaemon.client.deleteAgent(resumedAgent.id);
|
||||||
|
},
|
||||||
|
300000 // 5 minute timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -864,6 +864,15 @@ export class Session {
|
|||||||
await this.handleListCommandsRequest(msg.agentId, msg.requestId);
|
await this.handleListCommandsRequest(msg.agentId, msg.requestId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "execute_command_request":
|
||||||
|
await this.handleExecuteCommandRequest(
|
||||||
|
msg.agentId,
|
||||||
|
msg.commandName,
|
||||||
|
msg.args,
|
||||||
|
msg.requestId
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
case "register_push_token":
|
case "register_push_token":
|
||||||
this.handleRegisterPushToken(msg.token);
|
this.handleRegisterPushToken(msg.token);
|
||||||
break;
|
break;
|
||||||
@@ -1963,6 +1972,79 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle execute command request for an agent
|
||||||
|
*/
|
||||||
|
private async handleExecuteCommandRequest(
|
||||||
|
agentId: string,
|
||||||
|
commandName: string,
|
||||||
|
args: string | undefined,
|
||||||
|
requestId: string
|
||||||
|
): Promise<void> {
|
||||||
|
this.sessionLogger.debug(
|
||||||
|
{ agentId, commandName },
|
||||||
|
`Handling execute command request for agent ${agentId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agents = this.agentManager.listAgents();
|
||||||
|
const agent = agents.find((a) => a.id === agentId);
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
this.emit({
|
||||||
|
type: "execute_command_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
result: null,
|
||||||
|
error: `Agent not found: ${agentId}`,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = agent.session;
|
||||||
|
if (!session || !session.executeCommand) {
|
||||||
|
this.emit({
|
||||||
|
type: "execute_command_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
result: null,
|
||||||
|
error: `Agent does not support executing commands`,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await session.executeCommand(commandName, args);
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "execute_command_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
result,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
this.sessionLogger.error(
|
||||||
|
{ err: error, agentId, commandName },
|
||||||
|
"Failed to execute command"
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "execute_command_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
result: null,
|
||||||
|
error: error.message,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle agent permission response from user
|
* Handle agent permission response from user
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { Server as HTTPServer } from "http";
|
import type { Server as HTTPServer } from "http";
|
||||||
import type { IncomingMessage } from "http";
|
import type { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { parse as parseUrl } from "url";
|
import type { AgentRegistry } from "./agent/agent-registry.js";
|
||||||
import {
|
import type { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
WSInboundMessageSchema,
|
|
||||||
type WSOutboundMessage,
|
|
||||||
extractSessionMessage,
|
|
||||||
wrapSessionMessage,
|
|
||||||
} from "./messages.js";
|
|
||||||
import { Session } from "./session.js";
|
|
||||||
import { loadConversation } from "./persistence.js";
|
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
|
||||||
import { AgentRegistry } from "./agent/agent-registry.js";
|
|
||||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
|
||||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
|
||||||
import { PushTokenStore } from "./push/token-store.js";
|
|
||||||
import { PushService } from "./push/push-service.js";
|
|
||||||
import type { OpenAISTT } from "./agent/stt-openai.js";
|
import type { OpenAISTT } from "./agent/stt-openai.js";
|
||||||
import type { OpenAITTS } from "./agent/tts-openai.js";
|
import type { OpenAITTS } from "./agent/tts-openai.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
|
import type { WSOutboundMessage } from "./messages.js";
|
||||||
|
import { WebSocketSessionBridge } from "./websocket-session-bridge.js";
|
||||||
|
|
||||||
type AgentMcpClientConfig = {
|
type AgentMcpClientConfig = {
|
||||||
agentMcpUrl: string;
|
agentMcpUrl: string;
|
||||||
@@ -31,24 +20,12 @@ type WebSocketServerConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket server that routes messages between clients and their sessions.
|
* WebSocket server that only accepts sockets + parses/forwards messages to the session layer.
|
||||||
* This is a thin transport layer with no business logic.
|
|
||||||
*/
|
*/
|
||||||
export class VoiceAssistantWebSocketServer {
|
export class VoiceAssistantWebSocketServer {
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private wss: WebSocketServer;
|
private readonly wss: WebSocketServer;
|
||||||
private sessions: Map<WebSocket, Session> = new Map();
|
private readonly bridge: WebSocketSessionBridge;
|
||||||
private conversationIdToWs: Map<string, WebSocket> = new Map();
|
|
||||||
private clientIdCounter: number = 0;
|
|
||||||
private agentManager: AgentManager;
|
|
||||||
private agentRegistry: AgentRegistry;
|
|
||||||
private downloadTokenStore: DownloadTokenStore;
|
|
||||||
private pushTokenStore: PushTokenStore;
|
|
||||||
private pushService: PushService;
|
|
||||||
private readonly agentMcpConfig: AgentMcpClientConfig;
|
|
||||||
private readonly stt: OpenAISTT | null;
|
|
||||||
private readonly tts: OpenAITTS | null;
|
|
||||||
private readonly terminalManager: TerminalManager | null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
server: HTTPServer,
|
server: HTTPServer,
|
||||||
@@ -62,17 +39,15 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
terminalManager?: TerminalManager | null
|
terminalManager?: TerminalManager | null
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
this.agentManager = agentManager;
|
this.bridge = new WebSocketSessionBridge(
|
||||||
this.agentRegistry = agentRegistry;
|
this.logger,
|
||||||
this.downloadTokenStore = downloadTokenStore;
|
agentManager,
|
||||||
this.stt = speech?.stt ?? null;
|
agentRegistry,
|
||||||
this.tts = speech?.tts ?? null;
|
downloadTokenStore,
|
||||||
this.terminalManager = terminalManager ?? null;
|
agentMcpConfig,
|
||||||
|
speech,
|
||||||
const pushLogger = this.logger.child({ module: "push" });
|
terminalManager
|
||||||
this.pushTokenStore = new PushTokenStore(pushLogger);
|
);
|
||||||
this.pushService = new PushService(pushLogger, this.pushTokenStore);
|
|
||||||
this.agentMcpConfig = agentMcpConfig;
|
|
||||||
|
|
||||||
const { allowedOrigins } = wsConfig;
|
const { allowedOrigins } = wsConfig;
|
||||||
this.wss = new WebSocketServer({
|
this.wss = new WebSocketServer({
|
||||||
@@ -80,8 +55,6 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
path: "/ws",
|
path: "/ws",
|
||||||
verifyClient: ({ req }, callback) => {
|
verifyClient: ({ req }, callback) => {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
// Allow connections with no origin (native apps, curl, etc.)
|
|
||||||
// or from allowed origins (browsers)
|
|
||||||
if (!origin || allowedOrigins.has(origin)) {
|
if (!origin || allowedOrigins.has(origin)) {
|
||||||
callback(true);
|
callback(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -92,454 +65,19 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.wss.on("connection", (ws, request) => {
|
this.wss.on("connection", (ws, request) => {
|
||||||
this.handleConnection(ws, request);
|
void this.bridge.attach(ws, request);
|
||||||
});
|
|
||||||
|
|
||||||
this.agentManager.setAgentAttentionCallback((params) => {
|
|
||||||
this.broadcastAgentAttention(params);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.info("WebSocket server initialized on /ws");
|
this.logger.info("WebSocket server initialized on /ws");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle new WebSocket connection
|
|
||||||
*/
|
|
||||||
private async handleConnection(ws: WebSocket, request: IncomingMessage): Promise<void> {
|
|
||||||
// Generate unique client ID
|
|
||||||
const clientId = `client-${++this.clientIdCounter}`;
|
|
||||||
const connectionLogger = this.logger.child({ clientId });
|
|
||||||
|
|
||||||
// Extract conversation ID from URL query parameter if present
|
|
||||||
const url = parseUrl(request.url || "", true);
|
|
||||||
const conversationId = url.query.conversationId as string | undefined;
|
|
||||||
|
|
||||||
// Load conversation if ID provided
|
|
||||||
let initialMessages = null;
|
|
||||||
if (conversationId) {
|
|
||||||
connectionLogger.debug({ conversationId }, "Client requesting conversation");
|
|
||||||
initialMessages = await loadConversation(connectionLogger, conversationId);
|
|
||||||
|
|
||||||
if (initialMessages) {
|
|
||||||
connectionLogger.debug(
|
|
||||||
{ conversationId, messageCount: initialMessages.length },
|
|
||||||
"Loaded conversation"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
connectionLogger.debug({ conversationId }, "Conversation not found, starting fresh");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create session with message emission callback
|
|
||||||
const session = new Session(
|
|
||||||
clientId,
|
|
||||||
(msg) => {
|
|
||||||
this.sendToClient(ws, wrapSessionMessage(msg));
|
|
||||||
},
|
|
||||||
connectionLogger.child({ module: "session" }),
|
|
||||||
this.downloadTokenStore,
|
|
||||||
this.pushTokenStore,
|
|
||||||
this.agentManager,
|
|
||||||
this.agentRegistry,
|
|
||||||
this.agentMcpConfig,
|
|
||||||
this.stt,
|
|
||||||
this.tts,
|
|
||||||
this.terminalManager,
|
|
||||||
{
|
|
||||||
conversationId,
|
|
||||||
initialMessages: initialMessages || undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store session and reverse mapping
|
|
||||||
this.sessions.set(ws, session);
|
|
||||||
this.conversationIdToWs.set(session.getConversationId(), ws);
|
|
||||||
|
|
||||||
connectionLogger.info(
|
|
||||||
{ clientId, conversationId: session.getConversationId(), totalSessions: this.sessions.size },
|
|
||||||
"Client connected"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Don't send initial state here - client will request it via load_conversation_request
|
|
||||||
// This avoids race condition where message arrives before client sets up listeners
|
|
||||||
|
|
||||||
// Set up message handler
|
|
||||||
ws.on("message", (data) => {
|
|
||||||
this.handleMessage(ws, data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up close handler
|
|
||||||
ws.on("close", async () => {
|
|
||||||
const session = this.sessions.get(ws);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
connectionLogger.info(
|
|
||||||
{ clientId, totalSessions: this.sessions.size - 1 },
|
|
||||||
"Client disconnected"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up session
|
|
||||||
await session.cleanup();
|
|
||||||
|
|
||||||
// Remove from maps
|
|
||||||
this.sessions.delete(ws);
|
|
||||||
this.conversationIdToWs.delete(session.getConversationId());
|
|
||||||
|
|
||||||
connectionLogger.debug({ conversationId: session.getConversationId() }, "Conversation deleted");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up error handler
|
|
||||||
ws.on("error", async (error) => {
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
|
||||||
connectionLogger.error({ err }, "Client error");
|
|
||||||
const session = this.sessions.get(ws);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
// Clean up session
|
|
||||||
await session.cleanup();
|
|
||||||
|
|
||||||
// Remove from maps
|
|
||||||
this.sessions.delete(ws);
|
|
||||||
this.conversationIdToWs.delete(session.getConversationId());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming WebSocket message
|
|
||||||
*/
|
|
||||||
private async handleMessage(
|
|
||||||
ws: WebSocket,
|
|
||||||
data: Buffer | ArrayBuffer | Buffer[]
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Parse message
|
|
||||||
const parsed = JSON.parse(data.toString());
|
|
||||||
|
|
||||||
// Validate with Zod
|
|
||||||
const message = WSInboundMessageSchema.parse(parsed);
|
|
||||||
|
|
||||||
// Safe logging that handles large objects without crashing
|
|
||||||
const messageSummary = {
|
|
||||||
type: message.type,
|
|
||||||
...(message.type === "session" && message.message ? {
|
|
||||||
sessionMessageType: message.message.type,
|
|
||||||
} : {}),
|
|
||||||
};
|
|
||||||
this.logger.debug(messageSummary, "Received message");
|
|
||||||
|
|
||||||
// Handle WebSocket-level messages
|
|
||||||
switch (message.type) {
|
|
||||||
case "ping":
|
|
||||||
this.sendToClient(ws, { type: "pong" });
|
|
||||||
return;
|
|
||||||
|
|
||||||
case "recording_state":
|
|
||||||
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
|
|
||||||
return;
|
|
||||||
|
|
||||||
case "session":
|
|
||||||
// Extract and forward session message
|
|
||||||
const sessionMessage = extractSessionMessage(message);
|
|
||||||
if (sessionMessage) {
|
|
||||||
// Debug: Log create_agent_request details
|
|
||||||
if (sessionMessage.type === "create_agent_request") {
|
|
||||||
this.logger.debug({
|
|
||||||
cwd: sessionMessage.config.cwd,
|
|
||||||
initialMode: sessionMessage.config.modeId,
|
|
||||||
worktreeName: sessionMessage.worktreeName,
|
|
||||||
requestId: sessionMessage.requestId,
|
|
||||||
}, "create_agent_request details");
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = this.sessions.get(ws);
|
|
||||||
if (session) {
|
|
||||||
await session.handleMessage(sessionMessage);
|
|
||||||
} else {
|
|
||||||
this.logger.error("No session found for client");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
|
||||||
let rawPayload: string | null = null;
|
|
||||||
let parsedPayload: unknown = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const buffer = Array.isArray(data)
|
|
||||||
? Buffer.concat(
|
|
||||||
data.map((item) =>
|
|
||||||
Buffer.isBuffer(item)
|
|
||||||
? item
|
|
||||||
: Buffer.from(item as ArrayBuffer)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: Buffer.isBuffer(data)
|
|
||||||
? data
|
|
||||||
: Buffer.from(data as ArrayBuffer);
|
|
||||||
rawPayload = buffer.toString();
|
|
||||||
parsedPayload = JSON.parse(rawPayload);
|
|
||||||
} catch (payloadError) {
|
|
||||||
rawPayload = rawPayload ?? "<unreadable>";
|
|
||||||
parsedPayload = parsedPayload ?? rawPayload;
|
|
||||||
const payloadErr = payloadError instanceof Error ? payloadError : new Error(String(payloadError));
|
|
||||||
this.logger.error({ err: payloadErr }, "Failed to decode raw payload");
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedRawPayload =
|
|
||||||
typeof rawPayload === "string" && rawPayload.length > 2000
|
|
||||||
? `${rawPayload.slice(0, 2000)}... (truncated)`
|
|
||||||
: rawPayload;
|
|
||||||
|
|
||||||
this.logger.error({
|
|
||||||
err,
|
|
||||||
rawPayload: trimmedRawPayload,
|
|
||||||
parsedPayload,
|
|
||||||
}, "Failed to parse/handle message");
|
|
||||||
// Send error to client
|
|
||||||
this.sendToClient(
|
|
||||||
ws,
|
|
||||||
wrapSessionMessage({
|
|
||||||
type: "status",
|
|
||||||
payload: {
|
|
||||||
status: "error",
|
|
||||||
message: `Invalid message: ${err.message}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send message to specific client
|
|
||||||
*/
|
|
||||||
private sendToClient(ws: WebSocket, message: WSOutboundMessage): void {
|
|
||||||
if (ws.readyState === 1) {
|
|
||||||
// WebSocket.OPEN = 1
|
|
||||||
ws.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast message to all connected clients
|
|
||||||
*/
|
|
||||||
public broadcast(message: WSOutboundMessage): void {
|
public broadcast(message: WSOutboundMessage): void {
|
||||||
const payload = JSON.stringify(message);
|
this.bridge.broadcast(message);
|
||||||
this.sessions.forEach((_session, client) => {
|
|
||||||
if (client.readyState === 1) {
|
|
||||||
// WebSocket.OPEN = 1
|
|
||||||
client.send(payload);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the WebSocket server
|
|
||||||
*/
|
|
||||||
public async close(): Promise<void> {
|
public async close(): Promise<void> {
|
||||||
const cleanupPromises: Promise<void>[] = [];
|
await this.bridge.closeAll();
|
||||||
this.sessions.forEach((session, ws) => {
|
|
||||||
cleanupPromises.push(session.cleanup());
|
|
||||||
// Wait for WebSocket to actually close before resolving
|
|
||||||
cleanupPromises.push(
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
if (ws.readyState === WebSocket.CLOSED) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ws.once("close", () => resolve());
|
|
||||||
ws.close();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await Promise.all(cleanupPromises);
|
|
||||||
this.wss.close();
|
this.wss.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly ACTIVITY_THRESHOLD_MS = 120_000; // 2 minutes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get client activity state with computed staleness
|
|
||||||
*/
|
|
||||||
private getClientActivityState(session: Session): {
|
|
||||||
deviceType: "web" | "mobile" | null;
|
|
||||||
focusedAgentId: string | null;
|
|
||||||
isStale: boolean;
|
|
||||||
appVisible: boolean;
|
|
||||||
} {
|
|
||||||
const activity = session.getClientActivity();
|
|
||||||
if (!activity) {
|
|
||||||
this.logger.debug("getClientActivityState: no activity for session");
|
|
||||||
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
const ageMs = now - activity.lastActivityAt.getTime();
|
|
||||||
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS;
|
|
||||||
this.logger.debug({
|
|
||||||
deviceType: activity.deviceType,
|
|
||||||
focusedAgentId: activity.focusedAgentId,
|
|
||||||
lastActivityAt: activity.lastActivityAt.toISOString(),
|
|
||||||
ageMs,
|
|
||||||
isStale,
|
|
||||||
appVisible: activity.appVisible,
|
|
||||||
}, "getClientActivityState");
|
|
||||||
return {
|
|
||||||
deviceType: activity.deviceType,
|
|
||||||
focusedAgentId: activity.focusedAgentId,
|
|
||||||
isStale,
|
|
||||||
appVisible: activity.appVisible,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute shouldNotify for a specific client given all clients' states
|
|
||||||
*
|
|
||||||
* UX Rules:
|
|
||||||
* 1. If ANY client is actively watching the agent (focused + visible + not stale) → no notifications
|
|
||||||
* 2. If THIS client is not stale and focused elsewhere → notify THIS client (user is at computer)
|
|
||||||
* 3. If THIS client is stale → only notify if mobile, or if no mobile available
|
|
||||||
* 4. Don't notify non-stale clients that aren't focused on anything (just switched tabs)
|
|
||||||
*/
|
|
||||||
private computeShouldNotifyForClient(
|
|
||||||
clientState: {
|
|
||||||
deviceType: "web" | "mobile" | null;
|
|
||||||
focusedAgentId: string | null;
|
|
||||||
isStale: boolean;
|
|
||||||
appVisible: boolean;
|
|
||||||
},
|
|
||||||
allClientStates: Array<{
|
|
||||||
deviceType: "web" | "mobile" | null;
|
|
||||||
focusedAgentId: string | null;
|
|
||||||
isStale: boolean;
|
|
||||||
appVisible: boolean;
|
|
||||||
}>,
|
|
||||||
agentId: string
|
|
||||||
): boolean {
|
|
||||||
// Rule 1: If any client is actively watching the agent, no one needs notification
|
|
||||||
const isAnyoneActiveOnAgent = allClientStates.some(
|
|
||||||
(state) =>
|
|
||||||
state.focusedAgentId === agentId &&
|
|
||||||
state.appVisible &&
|
|
||||||
!state.isStale
|
|
||||||
);
|
|
||||||
if (isAnyoneActiveOnAgent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No heartbeat (legacy client or just connected) → notify
|
|
||||||
if (clientState.deviceType === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 2: If THIS client is not stale and actively looking at a different agent → notify them
|
|
||||||
if (!clientState.isStale && clientState.appVisible && clientState.focusedAgentId !== null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 3: If THIS client is not stale but just switched tabs (not focused on anything) → no notification
|
|
||||||
// User is present at the computer, they'll come back
|
|
||||||
if (!clientState.isStale) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 4: THIS client is stale - check if another client will handle it
|
|
||||||
const hasActiveWebClient = allClientStates.some(
|
|
||||||
(state) => state.deviceType === "web" && !state.isStale
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clientState.deviceType === "mobile") {
|
|
||||||
// Mobile only notifies if web is also stale (user truly away)
|
|
||||||
// If web is active, they'll see it there
|
|
||||||
return !hasActiveWebClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientState.deviceType === "web") {
|
|
||||||
// Stale web: notify only if no other client can handle it
|
|
||||||
// Other client = mobile or unknown (no heartbeat)
|
|
||||||
const hasOtherClient = allClientStates.some(
|
|
||||||
(state) => state !== clientState && (state.deviceType === "mobile" || state.deviceType === null)
|
|
||||||
);
|
|
||||||
return !hasOtherClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: notify
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast an attention_required event to all clients with per-client shouldNotify
|
|
||||||
*/
|
|
||||||
private broadcastAgentAttention(params: {
|
|
||||||
agentId: string;
|
|
||||||
provider: AgentProvider;
|
|
||||||
reason: "finished" | "error" | "permission";
|
|
||||||
}): void {
|
|
||||||
// Collect all client states first
|
|
||||||
const clientEntries: Array<{
|
|
||||||
ws: WebSocket;
|
|
||||||
state: {
|
|
||||||
deviceType: "web" | "mobile" | null;
|
|
||||||
focusedAgentId: string | null;
|
|
||||||
isStale: boolean;
|
|
||||||
appVisible: boolean;
|
|
||||||
};
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const [ws, session] of this.sessions) {
|
|
||||||
clientEntries.push({
|
|
||||||
ws,
|
|
||||||
state: this.getClientActivityState(session),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const allStates = clientEntries.map((e) => e.state);
|
|
||||||
|
|
||||||
this.logger.debug({
|
|
||||||
agentId: params.agentId,
|
|
||||||
reason: params.reason,
|
|
||||||
clientCount: clientEntries.length,
|
|
||||||
allStates,
|
|
||||||
}, "broadcastAgentAttention");
|
|
||||||
|
|
||||||
// Check if all clients are stale - if so, send push notification
|
|
||||||
const allClientsStale = allStates.every((state) => state.isStale);
|
|
||||||
this.logger.debug({ allClientsStale }, "Client staleness check");
|
|
||||||
if (allClientsStale) {
|
|
||||||
const tokens = this.pushTokenStore.getAllTokens();
|
|
||||||
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
|
||||||
if (tokens.length > 0) {
|
|
||||||
void this.pushService.sendPush(tokens, {
|
|
||||||
title: "Agent needs attention",
|
|
||||||
body: `Reason: ${params.reason}`,
|
|
||||||
data: { agentId: params.agentId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send to each client with their specific shouldNotify value
|
|
||||||
for (const { ws, state } of clientEntries) {
|
|
||||||
const shouldNotify = this.computeShouldNotifyForClient(
|
|
||||||
state,
|
|
||||||
allStates,
|
|
||||||
params.agentId
|
|
||||||
);
|
|
||||||
|
|
||||||
const message = wrapSessionMessage({
|
|
||||||
type: "agent_stream",
|
|
||||||
payload: {
|
|
||||||
agentId: params.agentId,
|
|
||||||
event: {
|
|
||||||
type: "attention_required",
|
|
||||||
provider: params.provider,
|
|
||||||
reason: params.reason,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
shouldNotify,
|
|
||||||
},
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sendToClient(ws, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import type { IncomingMessage } from "http";
|
||||||
|
import { parse as parseUrl } from "url";
|
||||||
|
import type { WebSocket } from "ws";
|
||||||
|
import {
|
||||||
|
WSInboundMessageSchema,
|
||||||
|
type WSOutboundMessage,
|
||||||
|
wrapSessionMessage,
|
||||||
|
} from "./messages.js";
|
||||||
|
import { Session } from "./session.js";
|
||||||
|
import { loadConversation } from "./persistence.js";
|
||||||
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
|
import { AgentRegistry } from "./agent/agent-registry.js";
|
||||||
|
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||||
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
|
import { PushTokenStore } from "./push/token-store.js";
|
||||||
|
import { PushService } from "./push/push-service.js";
|
||||||
|
import type { OpenAISTT } from "./agent/stt-openai.js";
|
||||||
|
import type { OpenAITTS } from "./agent/tts-openai.js";
|
||||||
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
|
import type pino from "pino";
|
||||||
|
|
||||||
|
type AgentMcpClientConfig = {
|
||||||
|
agentMcpUrl: string;
|
||||||
|
agentMcpHeaders?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WebSocketSessionBridge {
|
||||||
|
private readonly logger: pino.Logger;
|
||||||
|
private readonly sessions: Map<WebSocket, Session> = new Map();
|
||||||
|
private clientIdCounter = 0;
|
||||||
|
private readonly agentManager: AgentManager;
|
||||||
|
private readonly agentRegistry: AgentRegistry;
|
||||||
|
private readonly downloadTokenStore: DownloadTokenStore;
|
||||||
|
private readonly pushTokenStore: PushTokenStore;
|
||||||
|
private readonly pushService: PushService;
|
||||||
|
private readonly agentMcpConfig: AgentMcpClientConfig;
|
||||||
|
private readonly stt: OpenAISTT | null;
|
||||||
|
private readonly tts: OpenAITTS | null;
|
||||||
|
private readonly terminalManager: TerminalManager | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
logger: pino.Logger,
|
||||||
|
agentManager: AgentManager,
|
||||||
|
agentRegistry: AgentRegistry,
|
||||||
|
downloadTokenStore: DownloadTokenStore,
|
||||||
|
agentMcpConfig: AgentMcpClientConfig,
|
||||||
|
speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null },
|
||||||
|
terminalManager?: TerminalManager | null
|
||||||
|
) {
|
||||||
|
this.logger = logger.child({ module: "websocket-session-bridge" });
|
||||||
|
this.agentManager = agentManager;
|
||||||
|
this.agentRegistry = agentRegistry;
|
||||||
|
this.downloadTokenStore = downloadTokenStore;
|
||||||
|
this.agentMcpConfig = agentMcpConfig;
|
||||||
|
this.stt = speech?.stt ?? null;
|
||||||
|
this.tts = speech?.tts ?? null;
|
||||||
|
this.terminalManager = terminalManager ?? null;
|
||||||
|
|
||||||
|
const pushLogger = this.logger.child({ module: "push" });
|
||||||
|
this.pushTokenStore = new PushTokenStore(pushLogger);
|
||||||
|
this.pushService = new PushService(pushLogger, this.pushTokenStore);
|
||||||
|
|
||||||
|
this.agentManager.setAgentAttentionCallback((params) => {
|
||||||
|
this.broadcastAgentAttention(params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSessionCount(): number {
|
||||||
|
return this.sessions.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attach(ws: WebSocket, request: IncomingMessage): Promise<void> {
|
||||||
|
const clientId = `client-${++this.clientIdCounter}`;
|
||||||
|
const connectionLogger = this.logger.child({ clientId });
|
||||||
|
|
||||||
|
const url = parseUrl(request.url || "", true);
|
||||||
|
const conversationId = url.query.conversationId as string | undefined;
|
||||||
|
|
||||||
|
let initialMessages = null;
|
||||||
|
if (conversationId) {
|
||||||
|
connectionLogger.debug({ conversationId }, "Client requesting conversation");
|
||||||
|
initialMessages = await loadConversation(connectionLogger, conversationId);
|
||||||
|
|
||||||
|
if (initialMessages) {
|
||||||
|
connectionLogger.debug(
|
||||||
|
{ conversationId, messageCount: initialMessages.length },
|
||||||
|
"Loaded conversation"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
connectionLogger.debug({ conversationId }, "Conversation not found, starting fresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new Session(
|
||||||
|
clientId,
|
||||||
|
(msg) => {
|
||||||
|
this.sendToClient(ws, wrapSessionMessage(msg));
|
||||||
|
},
|
||||||
|
connectionLogger.child({ module: "session" }),
|
||||||
|
this.downloadTokenStore,
|
||||||
|
this.pushTokenStore,
|
||||||
|
this.agentManager,
|
||||||
|
this.agentRegistry,
|
||||||
|
this.agentMcpConfig,
|
||||||
|
this.stt,
|
||||||
|
this.tts,
|
||||||
|
this.terminalManager,
|
||||||
|
{
|
||||||
|
conversationId,
|
||||||
|
initialMessages: initialMessages || undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sessions.set(ws, session);
|
||||||
|
|
||||||
|
connectionLogger.info(
|
||||||
|
{ clientId, conversationId: session.getConversationId(), totalSessions: this.sessions.size },
|
||||||
|
"Client connected"
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.on("message", (data) => {
|
||||||
|
void this.handleRawMessage(ws, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", async () => {
|
||||||
|
await this.detach(ws, connectionLogger, clientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", async (error) => {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
connectionLogger.error({ err }, "Client error");
|
||||||
|
await this.detach(ws, connectionLogger, clientId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detach(ws: WebSocket, connectionLogger: pino.Logger, clientId: string): Promise<void> {
|
||||||
|
const session = this.sessions.get(ws);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
connectionLogger.info(
|
||||||
|
{ clientId, totalSessions: this.sessions.size - 1 },
|
||||||
|
"Client disconnected"
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.cleanup();
|
||||||
|
this.sessions.delete(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRawMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.toString());
|
||||||
|
const message = WSInboundMessageSchema.parse(parsed);
|
||||||
|
|
||||||
|
const messageSummary = {
|
||||||
|
type: message.type,
|
||||||
|
...(message.type === "session" && message.message
|
||||||
|
? { sessionMessageType: message.message.type }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
this.logger.debug(messageSummary, "Received message");
|
||||||
|
|
||||||
|
if (message.type === "ping") {
|
||||||
|
this.sendToClient(ws, { type: "pong" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "recording_state") {
|
||||||
|
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.sessions.get(ws);
|
||||||
|
if (!session) {
|
||||||
|
this.logger.error("No session found for client");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "session") {
|
||||||
|
if (message.message.type === "create_agent_request") {
|
||||||
|
this.logger.debug(
|
||||||
|
{
|
||||||
|
cwd: message.message.config.cwd,
|
||||||
|
initialMode: message.message.config.modeId,
|
||||||
|
worktreeName: message.message.worktreeName,
|
||||||
|
requestId: message.message.requestId,
|
||||||
|
},
|
||||||
|
"create_agent_request details"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await session.handleMessage(message.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
let rawPayload: string | null = null;
|
||||||
|
let parsedPayload: unknown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = Array.isArray(data)
|
||||||
|
? Buffer.concat(
|
||||||
|
data.map((item) => (Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer)))
|
||||||
|
)
|
||||||
|
: Buffer.isBuffer(data)
|
||||||
|
? data
|
||||||
|
: Buffer.from(data as ArrayBuffer);
|
||||||
|
rawPayload = buffer.toString();
|
||||||
|
parsedPayload = JSON.parse(rawPayload);
|
||||||
|
} catch (payloadError) {
|
||||||
|
rawPayload = rawPayload ?? "<unreadable>";
|
||||||
|
parsedPayload = parsedPayload ?? rawPayload;
|
||||||
|
const payloadErr =
|
||||||
|
payloadError instanceof Error ? payloadError : new Error(String(payloadError));
|
||||||
|
this.logger.error({ err: payloadErr }, "Failed to decode raw payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedRawPayload =
|
||||||
|
typeof rawPayload === "string" && rawPayload.length > 2000
|
||||||
|
? `${rawPayload.slice(0, 2000)}... (truncated)`
|
||||||
|
: rawPayload;
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
{
|
||||||
|
err,
|
||||||
|
rawPayload: trimmedRawPayload,
|
||||||
|
parsedPayload,
|
||||||
|
},
|
||||||
|
"Failed to parse/handle message"
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sendToClient(
|
||||||
|
ws,
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "error",
|
||||||
|
message: `Invalid message: ${err.message}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendToClient(ws: WebSocket, message: WSOutboundMessage): void {
|
||||||
|
// WebSocket.OPEN = 1
|
||||||
|
if (ws.readyState === 1) {
|
||||||
|
ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public broadcast(message: WSOutboundMessage): void {
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
for (const ws of this.sessions.keys()) {
|
||||||
|
if (ws.readyState === 1) {
|
||||||
|
ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async closeAll(): Promise<void> {
|
||||||
|
const cleanupPromises: Promise<void>[] = [];
|
||||||
|
for (const [ws, session] of this.sessions) {
|
||||||
|
cleanupPromises.push(session.cleanup());
|
||||||
|
cleanupPromises.push(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
// WebSocket.CLOSED = 3
|
||||||
|
if (ws.readyState === 3) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws.once("close", () => resolve());
|
||||||
|
ws.close();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(cleanupPromises);
|
||||||
|
this.sessions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ACTIVITY_THRESHOLD_MS = 120_000;
|
||||||
|
|
||||||
|
private getClientActivityState(session: Session): {
|
||||||
|
deviceType: "web" | "mobile" | null;
|
||||||
|
focusedAgentId: string | null;
|
||||||
|
isStale: boolean;
|
||||||
|
appVisible: boolean;
|
||||||
|
} {
|
||||||
|
const activity = session.getClientActivity();
|
||||||
|
if (!activity) {
|
||||||
|
this.logger.debug("getClientActivityState: no activity for session");
|
||||||
|
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const ageMs = now - activity.lastActivityAt.getTime();
|
||||||
|
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS;
|
||||||
|
this.logger.debug(
|
||||||
|
{
|
||||||
|
deviceType: activity.deviceType,
|
||||||
|
focusedAgentId: activity.focusedAgentId,
|
||||||
|
lastActivityAt: activity.lastActivityAt.toISOString(),
|
||||||
|
ageMs,
|
||||||
|
isStale,
|
||||||
|
appVisible: activity.appVisible,
|
||||||
|
},
|
||||||
|
"getClientActivityState"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
deviceType: activity.deviceType,
|
||||||
|
focusedAgentId: activity.focusedAgentId,
|
||||||
|
isStale,
|
||||||
|
appVisible: activity.appVisible,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeShouldNotifyForClient(
|
||||||
|
clientState: {
|
||||||
|
deviceType: "web" | "mobile" | null;
|
||||||
|
focusedAgentId: string | null;
|
||||||
|
isStale: boolean;
|
||||||
|
appVisible: boolean;
|
||||||
|
},
|
||||||
|
allClientStates: Array<{
|
||||||
|
deviceType: "web" | "mobile" | null;
|
||||||
|
focusedAgentId: string | null;
|
||||||
|
isStale: boolean;
|
||||||
|
appVisible: boolean;
|
||||||
|
}>,
|
||||||
|
agentId: string
|
||||||
|
): boolean {
|
||||||
|
const isAnyoneActiveOnAgent = allClientStates.some(
|
||||||
|
(state) => state.focusedAgentId === agentId && state.appVisible && !state.isStale
|
||||||
|
);
|
||||||
|
if (isAnyoneActiveOnAgent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientState.deviceType === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientState.isStale && clientState.appVisible && clientState.focusedAgentId !== null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientState.isStale) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveWebClient = allClientStates.some(
|
||||||
|
(state) => state.deviceType === "web" && !state.isStale
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clientState.deviceType === "mobile") {
|
||||||
|
return !hasActiveWebClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientState.deviceType === "web") {
|
||||||
|
const hasOtherClient = allClientStates.some(
|
||||||
|
(state) => state !== clientState && (state.deviceType === "mobile" || state.deviceType === null)
|
||||||
|
);
|
||||||
|
return !hasOtherClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastAgentAttention(params: {
|
||||||
|
agentId: string;
|
||||||
|
provider: AgentProvider;
|
||||||
|
reason: "finished" | "error" | "permission";
|
||||||
|
}): void {
|
||||||
|
const clientEntries: Array<{
|
||||||
|
ws: WebSocket;
|
||||||
|
state: {
|
||||||
|
deviceType: "web" | "mobile" | null;
|
||||||
|
focusedAgentId: string | null;
|
||||||
|
isStale: boolean;
|
||||||
|
appVisible: boolean;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const [ws, session] of this.sessions) {
|
||||||
|
clientEntries.push({
|
||||||
|
ws,
|
||||||
|
state: this.getClientActivityState(session),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allStates = clientEntries.map((e) => e.state);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
{
|
||||||
|
agentId: params.agentId,
|
||||||
|
reason: params.reason,
|
||||||
|
clientCount: clientEntries.length,
|
||||||
|
allStates,
|
||||||
|
},
|
||||||
|
"broadcastAgentAttention"
|
||||||
|
);
|
||||||
|
|
||||||
|
const allClientsStale = allStates.every((state) => state.isStale);
|
||||||
|
this.logger.debug({ allClientsStale }, "Client staleness check");
|
||||||
|
if (allClientsStale) {
|
||||||
|
const tokens = this.pushTokenStore.getAllTokens();
|
||||||
|
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
void this.pushService.sendPush(tokens, {
|
||||||
|
title: "Agent needs attention",
|
||||||
|
body: `Reason: ${params.reason}`,
|
||||||
|
data: { agentId: params.agentId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { ws, state } of clientEntries) {
|
||||||
|
const shouldNotify = this.computeShouldNotifyForClient(state, allStates, params.agentId);
|
||||||
|
|
||||||
|
const message = wrapSessionMessage({
|
||||||
|
type: "agent_stream",
|
||||||
|
payload: {
|
||||||
|
agentId: params.agentId,
|
||||||
|
event: {
|
||||||
|
type: "attention_required",
|
||||||
|
provider: params.provider,
|
||||||
|
reason: params.reason,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
shouldNotify,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sendToClient(ws, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -518,6 +518,14 @@ export const ListCommandsRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ExecuteCommandRequestSchema = z.object({
|
||||||
|
type: z.literal("execute_command_request"),
|
||||||
|
agentId: z.string(),
|
||||||
|
commandName: z.string(),
|
||||||
|
args: z.string().optional(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const RegisterPushTokenMessageSchema = z.object({
|
export const RegisterPushTokenMessageSchema = z.object({
|
||||||
type: z.literal("register_push_token"),
|
type: z.literal("register_push_token"),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
@@ -604,6 +612,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ClearAgentAttentionMessageSchema,
|
ClearAgentAttentionMessageSchema,
|
||||||
ClientHeartbeatMessageSchema,
|
ClientHeartbeatMessageSchema,
|
||||||
ListCommandsRequestSchema,
|
ListCommandsRequestSchema,
|
||||||
|
ExecuteCommandRequestSchema,
|
||||||
RegisterPushTokenMessageSchema,
|
RegisterPushTokenMessageSchema,
|
||||||
ListTerminalsRequestSchema,
|
ListTerminalsRequestSchema,
|
||||||
CreateTerminalRequestSchema,
|
CreateTerminalRequestSchema,
|
||||||
@@ -937,6 +946,22 @@ export const ListCommandsResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const AgentCommandResultSchema = z.object({
|
||||||
|
text: z.string(),
|
||||||
|
timeline: z.array(AgentTimelineItemPayloadSchema),
|
||||||
|
usage: AgentUsageSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ExecuteCommandResponseSchema = z.object({
|
||||||
|
type: z.literal("execute_command_response"),
|
||||||
|
payload: z.object({
|
||||||
|
agentId: z.string(),
|
||||||
|
result: AgentCommandResultSchema.nullable(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Terminal Outbound Messages
|
// Terminal Outbound Messages
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1037,6 +1062,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
GitRepoInfoResponseSchema,
|
GitRepoInfoResponseSchema,
|
||||||
ListProviderModelsResponseMessageSchema,
|
ListProviderModelsResponseMessageSchema,
|
||||||
ListCommandsResponseSchema,
|
ListCommandsResponseSchema,
|
||||||
|
ExecuteCommandResponseSchema,
|
||||||
ListTerminalsResponseSchema,
|
ListTerminalsResponseSchema,
|
||||||
CreateTerminalResponseSchema,
|
CreateTerminalResponseSchema,
|
||||||
SubscribeTerminalResponseSchema,
|
SubscribeTerminalResponseSchema,
|
||||||
@@ -1104,6 +1130,8 @@ export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessa
|
|||||||
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>;
|
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>;
|
||||||
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
|
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
|
||||||
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;
|
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;
|
||||||
|
export type ExecuteCommandRequest = z.infer<typeof ExecuteCommandRequestSchema>;
|
||||||
|
export type ExecuteCommandResponse = z.infer<typeof ExecuteCommandResponseSchema>;
|
||||||
export type RegisterPushTokenMessage = z.infer<typeof RegisterPushTokenMessageSchema>;
|
export type RegisterPushTokenMessage = z.infer<typeof RegisterPushTokenMessageSchema>;
|
||||||
|
|
||||||
// Terminal message types
|
// Terminal message types
|
||||||
|
|||||||
Reference in New Issue
Block a user