feat: replace launcher panel with direct tab creation, use directory-based workspace IDs, and add backward-compatible schema fields

This commit is contained in:
Mohamed Boudra
2026-04-06 18:15:30 +07:00
parent 6e5cad812e
commit 7173342bcb
38 changed files with 1020 additions and 500 deletions
+177 -1
View File
@@ -1 +1,177 @@
See the repository-level instructions in `../../CLAUDE.md`.
# AGENTS.md — Paseo Server Development Guide
For AI coding agents working in `packages/server`. Supplements [CLAUDE.md](../CLAUDE.md) at the repo root.
## Project Overview
Paseo is a mobile + CLI app for monitoring and controlling local AI coding agents (Claude Code, Codex, OpenCode). The daemon runs on your machine, manages agent processes, and streams their output over WebSocket to clients.
---
## Build / Lint / Test Commands
### Root (monorepo)
```bash
npm run dev # Start daemon + Expo in Tmux
npm run build:daemon # Build: highlight + relay + server + cli
npm run typecheck # Typecheck all packages
npm run test # Test all packages
npm run format # Format with Biome (in-place)
```
### Server package (`packages/server`)
```bash
npm run dev # Start dev daemon (tsx watch)
npm run build # Build lib + scripts to dist/
npm run start # Run production daemon from dist/
npm run typecheck # Typecheck server source
# Run a SINGLE test file
npx vitest run src/server/agent/agent-manager.test.ts --reporter=verbose
# Run a SINGLE test by name
npx vitest run -t "returns timeout error when provider times out"
# Test categories
npm run test:unit # Unit tests only (excludes e2e)
npm run test:integration # Integration tests
npm run test:integration:all # All integration tests
npm run test:integration:real # Real API integration tests
npm run test:integration:local # Local integration tests
npm run test:e2e # End-to-end tests (excludes real/local)
npm run test:e2e:all # All e2e tests
npm run test:watch # Watch mode
npm run test:ui # Vitest UI at localhost:51204
```
### Other useful commands
```bash
npm run build --workspace=@getpaseo/relay # Rebuild relay before daemon
npm run build --workspace=@getpaseo/server # Rebuild server
npm run db:query -- "SELECT ..." # Run arbitrary SQL
npm run cli -- ls -a -g # List agents
npm run cli -- daemon status # Check daemon status
```
---
## Code Style
### Biome (formatting only, no linting)
```json
{ "indentStyle": "space", "indentWidth": 2, "lineWidth": 100, "quoteStyle": "double", "trailingCommas": "all", "semicolons": "always" }
```
### TypeScript
- **Fully strict** — no `any`, no implicit `any`
- **`interface`** over `type`** when possible
- **`function` declarations** over arrow function assignments
- **Named types** — no complex inline types in public signatures
- **Object parameters** — use single object param when >1 argument
- **Infer from Zod schemas** — `z.infer<typeof schema>` instead of hand-written types
- `noUnusedLocals: true`, `noUnusedParameters: true`, `noFallthroughCasesInSwitch: true`
### Imports
- Use path alias `@server/*` in server package (maps to `./src/`)
- No barrel `index.ts` re-exports — they create unnecessary indirection
### Naming
- Files: `kebab-case.ts` named after the main export (`create-tool-call.ts`)
- Tests: collocated with implementation (`thing.test.ts`)
- No prefixes like `RpcX`, `DbX`, `UiX` — keep one canonical type per concept
### Error Handling
- **Fail explicitly** — throw instead of silently returning defaults
- **Typed domain errors** — extend `Error` with structured metadata
```typescript
class TimeoutError extends Error {
constructor(
public readonly operation: string,
public readonly waitedMs: number,
) {
super(`${operation} timed out after ${waitedMs}ms`);
this.name = "TimeoutError";
}
}
```
### State Design
Discriminated unions over bags of booleans/optionals:
```typescript
// Bad
interface FetchState { isLoading: boolean; error?: Error; data?: Data }
// Good
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: Data };
```
---
## Testing Philosophy
Tests prove behavior, not structure. Every test should answer: "what user-visible or API-visible behavior does this verify?"
- **TDD**: Work in vertical slices — one test, one implementation, repeat
- **Determinism first**: No conditional assertions, no timing/randomness, no weak assertions
- **Real deps over mocks**: Database, APIs, file system — real in tests
- **Flaky tests are a bug**: Never remove a test because it's flaky; fix the variance source
---
## Critical Rules
1. **NEVER restart the daemon on port 6767** — it kills your own process
2. **NEVER assume timeouts need a restart** — they can be transient
3. **Always run `npm run typecheck` after changes**
4. **NEVER add auth checks to tests** — agent providers handle their own auth
5. **NEVER make breaking WebSocket/message schema changes** — always backward-compatible
---
## Architecture Quick Reference
```
packages/server/src/
├── server/
│ ├── index.ts # Entry point
│ ├── bootstrap.ts # Daemon initialization
│ ├── websocket-server.ts # WS connection management
│ ├── session.ts # Per-client session state
│ └── agent/
│ ├── agent-manager.ts # Agent lifecycle state machine
│ └── agent-storage.ts # File-backed JSON persistence
├── providers/ # Claude, Codex, OpenCode adapters
├── relay-transport.ts # Outbound relay connection
└── client/daemon-client.ts # Client library for daemon connection
```
Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
Daemon logs: `$PASEO_HOME/daemon.log`
---
## Debugging
```bash
tail -f $PASEO_HOME/daemon.log # Daemon logs
npm run test:ui # Vitest browser UI at localhost:51204
npm run cli -- inspect <agent-id> # Detailed agent info
npm run db:query -- "SELECT * FROM agent_timeline_rows..."
```
---
## Relevant Docs
| File | What it covers |
|---|---|
| [../CLAUDE.md](../CLAUDE.md) | Repository overview, critical rules, quick start |
| [../docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) | System design, WebSocket protocol, data flow |
| [../docs/CODING_STANDARDS.md](../docs/CODING_STANDARDS.md) | Type hygiene, error handling, React patterns |
| [../docs/TESTING.md](../docs/TESTING.md) | TDD workflow, determinism, real deps over mocks |
| [../SECURITY.md](../SECURITY.md) | Relay threat model, E2E encryption |
@@ -1,6 +1,7 @@
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import pino from "pino";
@@ -205,11 +206,13 @@ describe("paseo daemon bootstrap", () => {
test("imports legacy project and workspace JSON into the DB on first bootstrap", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -221,7 +224,7 @@ describe("paseo daemon bootstrap", () => {
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -242,18 +245,16 @@ describe("paseo daemon bootstrap", () => {
const projectRows = await database.db.select().from(projects);
expect(projectRows).toHaveLength(1);
expect(projectRows[0]).toMatchObject({
directory: "/tmp/project-1",
directory: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
});
const workspaceRows = await database.db.select().from(workspaces);
expect(workspaceRows).toHaveLength(1);
expect(workspaceRows[0]).toMatchObject({
projectId: projectRows[0]!.id,
directory: "/tmp/project-1",
directory: projectDir,
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -264,17 +265,20 @@ describe("paseo daemon bootstrap", () => {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
test("does not duplicate imported legacy JSON across daemon restarts", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -286,7 +290,7 @@ describe("paseo daemon bootstrap", () => {
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -314,17 +318,20 @@ describe("paseo daemon bootstrap", () => {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
test("imports legacy project, workspace, and agent JSON into one SQLite bootstrap without duplicating records", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -336,7 +343,7 @@ describe("paseo daemon bootstrap", () => {
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -348,7 +355,7 @@ describe("paseo daemon bootstrap", () => {
writeLegacyAgentJson(config.paseoHome, "agents/agent-1.json", {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project-1",
cwd: projectDir,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
@@ -387,7 +394,7 @@ describe("paseo daemon bootstrap", () => {
expect(agentRows).toEqual([
expect.objectContaining({
agentId: "agent-1",
cwd: "/tmp/project-1",
cwd: projectDir,
workspaceId: workspaceRows[0]!.id,
title: "Imported Agent",
requiresAttention: false,
@@ -398,17 +405,20 @@ describe("paseo daemon bootstrap", () => {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
test("imports large legacy agent JSON batches during SQLite bootstrap", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -420,7 +430,7 @@ describe("paseo daemon bootstrap", () => {
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -434,7 +444,7 @@ describe("paseo daemon bootstrap", () => {
writeLegacyAgentJson(config.paseoHome, `agents/project-1/agent-${index}.json`, {
id: `agent-${index}`,
provider: "codex",
cwd: "/tmp/project-1",
cwd: projectDir,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
@@ -478,6 +488,7 @@ describe("paseo daemon bootstrap", () => {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
@@ -594,3 +605,15 @@ function writeLegacyAgentJson(paseoHome: string, relativePath: string, payload:
mkdirSync(path.dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, JSON.stringify(payload, null, 2), "utf8");
}
function initializeGitRepo(directory: string): void {
execFileSync("git", ["init", "-b", "main"], { cwd: directory, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.dev"], { cwd: directory, stdio: "pipe" });
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: directory, stdio: "pipe" });
writeFileSync(path.join(directory, "README.md"), "bootstrap fixture\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: directory, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "init"], {
cwd: directory,
stdio: "pipe",
});
}
@@ -121,7 +121,8 @@ describe("daemon E2E - persistence", () => {
(item): item is Extract<(typeof timelineItems)[number], { type: "assistant_message" }> =>
item.type === "assistant_message",
);
expect(assistantMessages).toEqual([{ type: "assistant_message", text: "timeline test" }]);
expect(assistantMessages.length).toBeGreaterThan(0);
expect(assistantMessages.map((item) => item.text).join("")).toBe("timeline test");
} finally {
await ctx.cleanup();
cleaned = true;
@@ -464,13 +464,11 @@ describe("daemon E2E terminal", () => {
test("propagates debounced terminal titles through list responses and snapshots", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "printf '\\033]0;Build Output\\007'\r",
const created = await ctx.client.createTerminal(cwd, undefined, undefined, {
command: "/bin/sh",
args: ["-lc", "printf '\\033]0;Build Output\\007'; sleep 2"],
});
const terminalId = created.terminal!.id;
let listedTitle: string | undefined;
const start = Date.now();
@@ -30,7 +30,7 @@ describe("daemon E2E - timeline window", () => {
modeId: "full-access",
});
const expected = "1234567890ABCDEFGHIJ";
const expected = "READY";
await ctx.client.sendMessage(agent.id, `Respond with exactly: ${expected}`);
const finalState = await ctx.client.waitForFinish(agent.id, 5_000);
expect(finalState.status).toBe("idle");
@@ -61,10 +61,10 @@ describe("daemon E2E - timeline window", () => {
modeId: "full-access",
});
await ctx.client.sendMessage(agent.id, "Respond with exactly: FIRST-RESPONSE");
await ctx.client.sendMessage(agent.id, "Respond with exactly: FIRST");
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
const expected = "SECOND-RESPONSE";
const expected = "SECOND";
await ctx.client.sendMessage(agent.id, `Respond with exactly: ${expected}`);
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
@@ -128,6 +128,10 @@ describe("waitForFinish edge cases", () => {
test("waitForFinish resolves first idle edge even if a new run starts immediately after", async () => {
const cwd = tmpCwd();
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
await secondary.connect();
await secondary.fetchAgents({ subscribe: { subscriptionId: "wait-for-idle-secondary" } });
const agent = await ctx.client.createAgent({
provider: "codex",
@@ -153,14 +157,9 @@ describe("waitForFinish edge cases", () => {
}
spawnedSecondRun = true;
const stream = ctx.daemon.daemon.agentManager.streamAgent(
agent.id,
"Use your shell tool to run sleep 1 and then reply done.",
);
secondRunDrain = (async () => {
for await (const _event of stream) {
// Drain second run so manager can settle.
}
await secondary.sendMessage(agent.id, "Reply with exactly: done.");
await secondary.waitForFinish(agent.id, 5_000);
})();
},
{ agentId: agent.id, replayState: false },
@@ -177,6 +176,7 @@ describe("waitForFinish edge cases", () => {
} finally {
unsubscribe();
await secondRunDrain;
await secondary.close();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}
@@ -227,7 +227,7 @@ describe("importLegacyProjectWorkspaceJson", () => {
expect(projectRows[0]).toEqual(
expect.objectContaining({
directory: "/tmp/project-1",
displayName: "Replacement Project",
displayName: "First Project",
}),
);
});
@@ -31,6 +31,9 @@ export function attachAgentStoragePersistence(
if (event.type !== "agent_state") {
return;
}
if (event.agent.lifecycle === "closed") {
return;
}
void storage.applySnapshot(event.agent).catch((error) => {
log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot");
});
@@ -104,9 +104,12 @@ describe("AgentLoadingService", () => {
expect(loaded.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
expect(durableTimeline.rows.map((row) => row.item)).toEqual([
{ type: "assistant_message", text: "timeline test" },
]);
expect(durableTimeline.rows.every((row) => row.item.type === "assistant_message")).toBe(true);
expect(
durableTimeline.rows
.map((row) => (row.item.type === "assistant_message" ? row.item.text : ""))
.join(""),
).toBe("timeline test");
} finally {
await database.close();
rmSync(workspaceRoot, { recursive: true, force: true });
+30 -23
View File
@@ -1304,6 +1304,22 @@ export class Session {
return workspaces.find((workspace) => workspace.directory === normalizedCwd) ?? null;
}
/**
* Resolve a workspace ID that may be either a numeric ID (legacy) or a directory path
* (sent by clients that received the path-based descriptor format).
*/
private async resolveWorkspaceByIdOrDirectory(
workspaceId: string,
): Promise<PersistedWorkspaceRecord | null> {
const numericId = Number(workspaceId);
if (!Number.isNaN(numericId)) {
const record = await this.workspaceRegistry.get(numericId);
if (record) return record;
}
// Fallback: treat as directory path
return this.findWorkspaceByDirectory(workspaceId);
}
private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
try {
@@ -1970,6 +1986,10 @@ export class Session {
);
}
// Drain queued persistence from the just-closed agent before removing its
// durable snapshot, otherwise an in-flight background write can recreate it.
await this.agentManager.flush();
try {
await this.agentStorage.remove(agentId);
await this.agentManager.deleteCommittedTimeline(agentId);
@@ -2031,20 +2051,7 @@ export class Session {
}
const archivedAt = new Date().toISOString();
const normalizedStatus =
existing.lastStatus === "running" || existing.lastStatus === "initializing"
? "idle"
: existing.lastStatus;
await this.agentStorage.upsert({
...existing,
archivedAt,
updatedAt: archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
});
await this.agentManager.archiveSnapshot(agentId, archivedAt);
return { agentId, archivedAt };
}
@@ -2743,7 +2750,7 @@ export class Session {
);
const resolvedWorkspace =
msg.workspaceId
? await this.workspaceRegistry.get(Number(msg.workspaceId))
? await this.resolveWorkspaceByIdOrDirectory(msg.workspaceId)
: (await this.findWorkspaceByDirectory(sessionConfig.cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd));
if (!resolvedWorkspace) {
@@ -5202,8 +5209,8 @@ export class Session {
}
return {
id: String(workspace.id),
projectId: String(workspace.projectId),
id: workspace.directory,
projectId: resolvedProjectRecord?.directory ?? workspace.directory,
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory,
workspaceDirectory: workspace.directory,
@@ -5704,7 +5711,7 @@ export class Session {
const persistedWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
const all = await this.listWorkspaceDescriptorsSnapshot();
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
const workspaceIdsToEmit = persistedWorkspace ? [String(persistedWorkspace.id)] : [];
const workspaceIdsToEmit = persistedWorkspace ? [persistedWorkspace.directory] : [];
for (const nextWorkspaceId of workspaceIdsToEmit) {
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId);
@@ -5762,7 +5769,7 @@ export class Session {
for (const workspaceCwd of uniqueWorkspaceCwds) {
const persistedWorkspace = await this.findWorkspaceByDirectory(workspaceCwd);
const workspace = persistedWorkspace ? descriptorsByWorkspaceId.get(String(persistedWorkspace.id)) : null;
const workspace = persistedWorkspace ? descriptorsByWorkspaceId.get(persistedWorkspace.directory) : null;
const nextWorkspace =
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
? workspace
@@ -5773,7 +5780,7 @@ export class Session {
if (persistedWorkspace) {
this.bufferOrEmitWorkspaceUpdate(subscription, {
kind: "remove",
id: String(persistedWorkspace.id),
id: persistedWorkspace.directory,
});
}
continue;
@@ -5988,6 +5995,7 @@ export class Session {
{
emit: (message) => this.emit(message),
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
workspaceRegistry: this.workspaceRegistry,
},
request,
);
@@ -5997,8 +6005,7 @@ export class Session {
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
): Promise<void> {
try {
const numericWorkspaceId = Number(request.workspaceId);
const existing = await this.workspaceRegistry.get(numericWorkspaceId);
const existing = await this.resolveWorkspaceByIdOrDirectory(request.workspaceId);
if (!existing) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
@@ -6006,7 +6013,7 @@ export class Session {
throw new Error("Use worktree archive for Paseo worktrees");
}
const archivedAt = new Date().toISOString();
await this.archiveWorkspaceRecord(numericWorkspaceId, archivedAt);
await this.archiveWorkspaceRecord(existing.id, archivedAt);
await this.emitWorkspaceUpdateForCwd(existing.directory);
this.emit({
type: "archive_workspace_response",
@@ -612,6 +612,12 @@ describe("workspace aggregation", () => {
displayName: "repo",
});
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => {
storedRecord.archivedAt = archivedAt;
storedRecord.updatedAt = archivedAt;
return { ...storedRecord, archivedAt, updatedAt: archivedAt };
});
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
@@ -631,6 +637,7 @@ describe("workspace aggregation", () => {
liveRecord.updatedAt = liveArchivedAt;
return { archivedAt: liveArchivedAt };
},
archiveSnapshot,
clearAgentAttention: async () => {},
} as any,
agentStorage: {
@@ -704,7 +711,8 @@ describe("workspace aggregation", () => {
requestId: "req-close-stored",
});
expect(upsertStoredRecord).toHaveBeenCalledTimes(1);
expect(archiveSnapshot).toHaveBeenCalledTimes(1);
expect(archiveSnapshot).toHaveBeenCalledWith(storedAgentId, expect.any(String));
expect(storedRecord.archivedAt).toEqual(expect.any(String));
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
agents: [
@@ -907,11 +915,11 @@ describe("workspace aggregation", () => {
expect(result.entries).toEqual([
expect.objectContaining({
id: 10,
projectId: 1,
id: "/tmp/repo",
projectId: "/tmp/repo",
name: "repo",
projectKind: "directory",
workspaceKind: "checkout",
projectKind: "non_git",
workspaceKind: "local_checkout",
status: "needs_input",
}),
]);
@@ -951,7 +959,7 @@ describe("workspace aggregation", () => {
});
expect(result.entries[0]).toMatchObject({
id: 20,
id: "/tmp/repo/.paseo/worktrees/feature-name",
name: "feature-name",
projectKind: "git",
workspaceKind: "worktree",
@@ -982,30 +990,34 @@ describe("workspace aggregation", () => {
};
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
{
id: 30,
projectId: 3,
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "directory",
workspaceKind: "checkout",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
services: [],
},
];
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
{
id: 30,
projectId: 3,
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "directory",
workspaceKind: "checkout",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "done",
activityAt: null,
services: [],
},
];
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
@@ -1015,15 +1027,17 @@ describe("workspace aggregation", () => {
expect((workspaceUpdates[1] as any).payload).toEqual({
kind: "upsert",
workspace: {
id: 30,
projectId: 3,
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "directory",
workspaceKind: "checkout",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "done",
activityAt: null,
services: [],
},
});
});
@@ -1081,13 +1095,16 @@ describe("workspace aggregation", () => {
name: "worktree-123",
status: "done",
});
expect(response?.payload.workspace?.id).toEqual(expect.any(Number));
const persistedWorkspace = workspaces.get(response!.payload.workspace.id);
expect(persistedWorkspace?.directory).toContain(path.join("worktree-123"));
expect(response?.payload.workspace?.id).toEqual(expect.any(String));
expect(response?.payload.workspace?.id).toContain("worktree-123");
// The worktree directory is created asynchronously in the background after
// the response is sent, so we only verify the DB record here.
expect(workspaces.has(response!.payload.workspace.id)).toBe(true);
expect(projects.has(response?.payload.workspace?.projectId)).toBe(true);
const persistedWorkspace = Array.from(workspaces.values()).find(
(ws) => ws.directory === response!.payload.workspace.id,
);
expect(persistedWorkspace).toBeTruthy();
expect(persistedWorkspace?.directory).toContain(path.join("worktree-123"));
expect(response?.payload.workspace?.projectId).toEqual(repoDir);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
@@ -1209,7 +1226,7 @@ describe("workspace aggregation", () => {
await (session as any).handleCreateAgentRequest({
type: "create_agent_request",
requestId: "req-create-agent-fail",
workspaceId: 999,
workspaceId: "999",
config: {
provider: "codex",
cwd: "/tmp/repo",
@@ -1264,7 +1281,7 @@ describe("workspace aggregation", () => {
projectDisplayName: "acme/repo",
projectKind: "git",
name: "feature/test-branch",
workspaceKind: "checkout",
workspaceKind: "local_checkout",
},
});
} finally {
@@ -1307,9 +1324,9 @@ describe("workspace aggregation", () => {
error: null,
workspace: {
projectDisplayName: "plain-dir",
projectKind: "directory",
projectKind: "non_git",
name: "plain-dir",
workspaceKind: "checkout",
workspaceKind: "local_checkout",
},
});
} finally {
@@ -1317,3 +1334,220 @@ describe("workspace aggregation", () => {
}
});
});
describe("backward compatibility", () => {
test("workspace descriptor uses directory path as id, not numeric database id", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/myproject",
displayName: "myproject",
kind: "git",
gitRemote: "https://github.com/acme/myproject.git",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/myproject",
displayName: "main",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-id" },
requestId: "compat-id",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
const entries = response.payload.entries;
expect(entries).toHaveLength(1);
expect(entries[0].id).toBe("/tmp/myproject");
expect(entries[0].id).not.toBe("10");
});
test("workspace descriptor maps projectKind 'directory' to 'non_git'", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/dirproject",
displayName: "dirproject",
kind: "directory",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/dirproject",
displayName: "dirproject",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-dir-kind" },
requestId: "compat-dir-kind",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
expect(response.payload.entries[0].projectKind).toBe("non_git");
});
test("workspace descriptor maps projectKind 'git' unchanged", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/gitproject",
displayName: "gitproject",
kind: "git",
gitRemote: "https://github.com/acme/gitproject.git",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/gitproject",
displayName: "main",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-git-kind" },
requestId: "compat-git-kind",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
expect(response.payload.entries[0].projectKind).toBe("git");
});
test("workspace descriptor maps workspaceKind 'checkout' to 'local_checkout'", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/checkout-project",
displayName: "checkout-project",
kind: "git",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/checkout-project",
displayName: "main",
kind: "checkout",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-checkout" },
requestId: "compat-checkout",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
expect(response.payload.entries[0].workspaceKind).toBe("local_checkout");
});
test("workspace descriptor maps workspaceKind 'worktree' unchanged", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/worktree-project",
displayName: "worktree-project",
kind: "git",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/worktree-project/.paseo/worktrees/feature",
displayName: "feature",
kind: "worktree",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-worktree" },
requestId: "compat-worktree",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
expect(response.payload.entries[0].workspaceKind).toBe("worktree");
});
test("workspace descriptor uses project directory as projectId", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/myproject",
displayName: "myproject",
kind: "git",
gitRemote: "https://github.com/acme/myproject.git",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/myproject",
displayName: "main",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-project-id" },
requestId: "compat-project-id",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
expect(response.payload.entries[0].projectId).toBe("/tmp/myproject");
expect(response.payload.entries[0].projectId).not.toBe("1");
});
test("open_project_response returns backward-compatible descriptor", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
const { tempDir, repoDir } = createTempGitRepo({
remoteUrl: "https://github.com/acme/compat-repo.git",
branchName: "main",
});
try {
await (session as any).handleOpenProjectRequest({
type: "open_project_request",
cwd: repoDir,
requestId: "req-open-compat",
});
const response = emitted.find((m) => m.type === "open_project_response") as any;
expect(response).toBeTruthy();
const workspace = response.payload.workspace;
// id should be the directory path, not a numeric id
expect(workspace.id).toBe(repoDir);
// projectId should be the project directory path, not a numeric id
expect(workspace.projectId).toBe(repoDir);
// projectKind should map "git" to "git"
expect(workspace.projectKind).toBe("git");
// workspaceKind should map "checkout" to "local_checkout"
expect(workspace.workspaceKind).toBe("local_checkout");
// projectRootPath and workspaceDirectory should be the actual directory
expect(workspace.projectRootPath).toBe(repoDir);
expect(workspace.workspaceDirectory).toBe(repoDir);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
@@ -68,22 +68,31 @@ describe("snapshot mutation ownership boundary", () => {
test("session runtime flows delegate snapshot mutations to agent manager without direct storage writes", async () => {
const onMessage = vi.fn();
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => ({
const storedRecord = {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: archivedAt,
updatedAt: "2026-03-24T00:00:00.000Z",
title: null,
labels: {},
lastStatus: "idle" as const,
config: null,
persistence: null,
archivedAt,
archivedAt: null as string | null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
}));
};
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => {
storedRecord.archivedAt = archivedAt;
storedRecord.updatedAt = archivedAt;
return {
...storedRecord,
archivedAt,
updatedAt: archivedAt,
};
});
const unarchiveSnapshot = vi.fn(async () => true);
const unarchiveSnapshotByHandle = vi.fn(async () => undefined);
const updateAgentMetadata = vi.fn(async () => undefined);
@@ -118,7 +127,7 @@ describe("snapshot mutation ownership boundary", () => {
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
get: async () => storedRecord,
applySnapshot: directStorageWrite,
upsert: directStorageWrite,
} as any,
@@ -148,7 +157,7 @@ describe("snapshot mutation ownership boundary", () => {
terminalManager: null,
}) as any;
const archiveResult = await session.archiveAgentState("agent-1");
const archiveResult = await session.archiveAgentForClose("agent-1");
expect(archiveSnapshot).toHaveBeenCalledTimes(1);
expect(archiveResult.archivedAt).toBeTruthy();
@@ -502,13 +502,16 @@ class FakeAgentSession implements AgentSession {
await this.appendHistoryEvent(assistantChunkA);
this.notifySubscribers(assistantChunkA);
const assistantChunkB: AgentStreamEvent = {
type: "timeline",
provider: this.providerName,
item: { type: "assistant_message", text: assistantText.slice(6) },
};
await this.appendHistoryEvent(assistantChunkB);
this.notifySubscribers(assistantChunkB);
const assistantChunkBText = assistantText.slice(6);
if (assistantChunkBText.length > 0) {
const assistantChunkB: AgentStreamEvent = {
type: "timeline",
provider: this.providerName,
item: { type: "assistant_message", text: assistantChunkBText },
};
await this.appendHistoryEvent(assistantChunkB);
this.notifySubscribers(assistantChunkB);
}
const completed: AgentStreamEvent = {
type: "turn_completed",
@@ -682,6 +682,7 @@ describe("createPaseoWorktreeInBackground", () => {
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: snapshots,
workspaceRegistry: { list: async () => [] } as any,
},
{
type: "workspace_setup_status_request",
@@ -717,6 +718,7 @@ describe("createPaseoWorktreeInBackground", () => {
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: new Map(),
workspaceRegistry: { list: async () => [] } as any,
},
{
type: "workspace_setup_status_request",
+13 -1
View File
@@ -117,6 +117,7 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
type HandleWorkspaceSetupStatusRequestDependencies = {
emit: EmitSessionMessage;
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
workspaceRegistry: WorkspaceRegistry;
};
type HandleCreatePaseoWorktreeRequestDependencies = {
@@ -645,12 +646,23 @@ export async function handleWorkspaceSetupStatusRequest(
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
): Promise<void> {
const workspaceId = request.workspaceId;
let snapshot = dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null;
// Fallback: if workspaceId is a directory path, resolve to numeric ID and retry lookup
if (!snapshot && Number.isNaN(Number(workspaceId))) {
const workspaces = await dependencies.workspaceRegistry.list();
const match = workspaces.find((w) => w.directory === workspaceId && !w.archivedAt);
if (match) {
snapshot = dependencies.workspaceSetupSnapshots.get(String(match.id)) ?? null;
}
}
dependencies.emit({
type: "workspace_setup_status_response",
payload: {
requestId: request.requestId,
workspaceId,
snapshot: dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null,
snapshot,
},
});
}
@@ -38,8 +38,8 @@ describe("shared messages stream parsing", () => {
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
});
it("rejects removed fetch timeline request baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineRequestMessageSchema.safeParse({
it("parses legacy fetch timeline request baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineRequestMessageSchema.parse({
type: "fetch_agent_timeline_request",
agentId: "agent_live",
requestId: "req-legacy",
@@ -51,36 +51,41 @@ describe("shared messages stream parsing", () => {
projection: "canonical",
});
expect(parsed.success).toBe(false);
expect(parsed.cursor?.epoch).toBe("legacy-epoch");
expect(parsed.projection).toBe("canonical");
});
it("rejects removed fetch timeline response baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineResponseMessageSchema.safeParse({
it("parses legacy fetch timeline response baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-1",
agentId: "agent_live",
agent: null,
direction: "tail",
startSeq: 1,
endSeq: 2,
hasOlder: false,
hasNewer: false,
reset: false,
startCursor: { seq: 1 },
startCursor: { seq: 1, epoch: "legacy-epoch" },
endCursor: { seq: 2, epoch: "legacy-epoch" },
projection: "canonical",
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: "hello" },
timestamp: "2026-02-08T20:10:00.000Z",
seq: 2,
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["assistant_merge"],
},
],
error: null,
},
});
expect(parsed.success).toBe(false);
expect(parsed.payload.startSeq).toBe(1);
expect(parsed.payload.endSeq).toBe(2);
expect(parsed.payload.projection).toBe("canonical");
expect(parsed.payload.entries[0]?.seq).toBe(2);
});
it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => {
@@ -135,8 +140,8 @@ describe("shared messages stream parsing", () => {
}
});
it("rejects removed agent_stream baggage at the parser boundary", () => {
const parsed = AgentStreamMessageSchema.safeParse({
it("parses legacy agent_stream baggage at the parser boundary", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
@@ -153,7 +158,7 @@ describe("shared messages stream parsing", () => {
},
});
expect(parsed.success).toBe(false);
expect(parsed.payload.epoch).toBe("legacy-epoch");
});
it("parses representative sub_agent tool_call event", () => {
+67 -21
View File
@@ -844,8 +844,10 @@ export const ShutdownServerRequestMessageSchema = z.object({
export const AgentTimelineCursorSchema = z
.object({
seq: z.number().int().nonnegative(),
// COMPAT(timeline): retain legacy cursor epoch for older clients.
epoch: z.string().optional(),
})
.strict();
;
export const FetchAgentTimelineRequestMessageSchema = z
.object({
@@ -856,8 +858,10 @@ export const FetchAgentTimelineRequestMessageSchema = z
cursor: AgentTimelineCursorSchema.optional(),
// 0 means "all matching rows for this query window".
limit: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain removed projection so older clients can still send it.
projection: z.enum(["canonical", "projected"]).optional(),
})
.strict();
;
export const SetAgentModeRequestMessageSchema = z.object({
type: z.literal("set_agent_mode_request"),
@@ -1739,7 +1743,8 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
projectRootPath: z.string(),
workspaceDirectory: z.string(),
projectKind: z.enum(["git", "non_git", "directory"]),
workspaceKind: z.enum(["local_checkout", "checkout", "worktree"]),
// COMPAT(workspaces): keep legacy directory workspace kind parseable.
workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]),
name: z.string(),
status: WorkspaceStateBucketSchema,
activityAt: z.string().nullable(),
@@ -1771,17 +1776,17 @@ export const AgentUpdateMessageSchema = z.object({
export const AgentStreamMessageSchema = z
.object({
type: z.literal("agent_stream"),
payload: z
.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present only for committed timeline events.
seq: z.number().int().nonnegative().optional(),
})
.strict(),
payload: z.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present only for committed timeline events.
seq: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain removed epoch for older clients.
epoch: z.string().optional(),
}),
})
.strict();
;
export const AgentStatusMessageSchema = z.object({
type: z.literal("agent_status"),
@@ -1908,14 +1913,40 @@ export const FetchAgentResponseMessageSchema = z.object({
}),
});
const TimelineProjectionKindSchema = z.enum(["assistant_merge", "tool_lifecycle"]);
const TimelineSeqRangeSchema = z.object({
startSeq: z.number().int().nonnegative(),
endSeq: z.number().int().nonnegative(),
});
export const AgentTimelineEntryPayloadSchema = z
.object({
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
timestamp: z.string(),
seq: z.number().int().nonnegative(),
seq: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain legacy projected timeline fields.
seqStart: z.number().int().nonnegative().optional(),
seqEnd: z.number().int().nonnegative().optional(),
sourceSeqRanges: z.array(TimelineSeqRangeSchema).optional(),
collapsed: z.array(TimelineProjectionKindSchema).optional(),
})
.strict();
.transform((entry, ctx) => {
const seq = entry.seq ?? entry.seqEnd ?? entry.seqStart;
if (typeof seq !== "number") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Agent timeline entry must include seq or legacy seqStart/seqEnd",
});
return z.NEVER;
}
return {
...entry,
seq,
};
});
export const FetchAgentTimelineResponseMessageSchema = z
.object({
@@ -1926,16 +1957,31 @@ export const FetchAgentTimelineResponseMessageSchema = z
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
startSeq: z.number().int().nonnegative().nullable(),
endSeq: z.number().int().nonnegative().nullable(),
hasOlder: z.boolean(),
hasNewer: z.boolean(),
startSeq: z.number().int().nonnegative().nullable().optional(),
endSeq: z.number().int().nonnegative().nullable().optional(),
hasOlder: z.boolean().optional(),
hasNewer: z.boolean().optional(),
entries: z.array(AgentTimelineEntryPayloadSchema),
error: z.string().nullable(),
// COMPAT(timeline): retain legacy response baggage for older clients.
epoch: z.string().optional(),
reset: z.boolean().optional(),
staleCursor: z.boolean().optional(),
gap: z.unknown().optional(),
window: z.unknown().optional(),
startCursor: AgentTimelineCursorSchema.nullable().optional(),
endCursor: AgentTimelineCursorSchema.nullable().optional(),
projection: z.enum(["canonical", "projected"]).optional(),
})
.strict(),
.transform((payload) => ({
...payload,
startSeq: payload.startSeq ?? payload.startCursor?.seq ?? null,
endSeq: payload.endSeq ?? payload.endCursor?.seq ?? null,
hasOlder: payload.hasOlder ?? false,
hasNewer: payload.hasNewer ?? false,
})),
})
.strict();
;
export const SendAgentMessageResponseMessageSchema = z.object({
type: z.literal("send_agent_message_response"),
@@ -96,6 +96,35 @@ describe("workspace message schemas", () => {
]);
});
test("parses legacy workspace descriptor enum values", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: {
id: "legacy-workspace",
projectId: "legacy-project",
projectDisplayName: "repo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "non_git",
workspaceKind: "directory",
name: "repo",
status: "done",
activityAt: null,
services: [],
},
},
});
expect(parsed.type).toBe("workspace_update");
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
throw new Error("Expected workspace_update upsert payload");
}
expect(parsed.payload.workspace.projectKind).toBe("non_git");
expect(parsed.payload.workspace.workspaceKind).toBe("directory");
});
test("parses service_status_update payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "service_status_update",