Rename the persistent agent integration to Sybil Agent

Use SYBIL_AGENT_BASE_URL and SYBIL_AGENT_API_KEY with the renamed sybil-agent
service. Update errors, API documentation, and test fixtures while retaining the
existing room protocol and browser storage keys for compatibility.

Validation: 44 server tests, 51 web tests, and both production builds.
This commit is contained in:
2026-10-14 12:37:03 -07:00
parent ef3d79c428
commit e83281c400
9 changed files with 52 additions and 50 deletions
+10 -10
View File
@@ -563,20 +563,20 @@ Behavior notes:
For streaming contracts, see `docs/api/streaming-chat.md`. For streaming contracts, see `docs/api/streaming-chat.md`.
## Persistent Agent Room ## Sybil Agent Room
The Agent tab connects to one persistent Pi root conversation. Pi owns its messages, The Sybil Agent tab connects to one persistent root conversation. The agent owns its messages,
background work, and history; these endpoints do not create ordinary Sybil chats or background work, and history; these endpoints do not create ordinary Sybil chats or
copy the transcript into the Sybil message database. All endpoints use normal Sybil copy the transcript into the Sybil message database. All endpoints use normal Sybil
REST authentication. The server connects privately to `PI_AGENT_BASE_URL` (default REST authentication. The server connects privately to `SYBIL_AGENT_BASE_URL` (default
`http://pi-durable:8788`) using `PI_AGENT_API_KEY`. The Pi key never reaches browsers. `http://sybil-agent:8788`) using `SYBIL_AGENT_API_KEY`. The agent key never reaches browsers.
### `GET /v1/agent` ### `GET /v1/agent`
Returns the Pi room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`, Returns the Sybil Agent room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`,
`protocol: "pi-room.v1"`, `activity`, and `websocket: "/v1/agent/ws"`. The socket path `protocol: "pi-room.v1"`, `activity`, and `websocket: "/v1/agent/ws"`. The socket path
is relative to the API base, so web deployments use `/api/v1/agent/ws`. Missing Pi is relative to the API base, so web deployments use `/api/v1/agent/ws`. Missing agent
configuration returns 503; an unreachable Pi server or malformed upstream JSON configuration returns 503; an unreachable agent server or malformed upstream JSON
returns 502. returns 502.
### `GET /v1/agent/messages` ### `GET /v1/agent/messages`
@@ -588,7 +588,7 @@ history. `after` catches up forward; `before` loads earlier history.
```json ```json
{ {
"conversationId": "1", "conversationId": "1",
"messages": [{ "id": "outbound:43", "createdAt": "2026-10-14T18:00:00.000Z", "role": "assistant", "content": "The task finished.", "name": "Pi", "metadata": { "kind": "message", "entryId": 43, "status": "complete" } }], "messages": [{ "id": "outbound:43", "createdAt": "2026-10-14T18:00:00.000Z", "role": "assistant", "content": "The task finished.", "name": "Sybil Agent", "metadata": { "kind": "message", "entryId": 43, "status": "complete" } }],
"cursor": "43", "cursor": "43",
"beforeCursor": "43", "beforeCursor": "43",
"hasMore": false "hasMore": false
@@ -611,10 +611,10 @@ Content must contain non-whitespace text and is limited to 40,000 characters;
network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
`wait: false`. `wait: false`.
Returns Pi's `202 Accepted` receipt, including `id` (submission ID), `status`, and Returns the agent's `202 Accepted` receipt, including `id` (submission ID), `status`, and
`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not `messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not
completion: watch the room for answers, including unsolicited later replies. completion: watch the room for answers, including unsolicited later replies.
Multiple requests can be accepted while Pi works. Attachments are not yet supported. Multiple requests can be accepted while the agent works. Attachments are not yet supported.
The proxy verifies successful receipts contain a positive submission ID, a known The proxy verifies successful receipts contain a positive submission ID, a known
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`. status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`.
Invalid receipts return 502; delivery remains uncertain, so retry using the original Invalid receipts return 502; delivery remains uncertain, so retry using the original
+8 -7
View File
@@ -276,7 +276,7 @@ data: {"type":"done","text":"Hello world"}
``` ```
## Persistent Agent WebSocket ## Sybil Agent WebSocket
`GET /v1/agent/ws` upgrades to a continuous WebSocket room subscription. Behind the `GET /v1/agent/ws` upgrades to a continuous WebSocket room subscription. Behind the
web proxy use `/api/v1/agent/ws`, with `ws:` or `wss:` matching the page. It remains web proxy use `/api/v1/agent/ws`, with `ws:` or `wss:` matching the page. It remains
@@ -293,10 +293,11 @@ In open mode, use `token: null`. Omit `after` for recent history; on reconnect s
the last successfully merged cursor. Invalid credentials close with 4401, malformed the last successfully merged cursor. Invalid credentials close with 4401, malformed
initial frames with 4400. Malformed upgrade URLs and query parameters are rejected initial frames with 4400. Malformed upgrade URLs and query parameters are rejected
with HTTP 400; unknown upgrade paths return HTTP 404. Never put tokens in URLs. with HTTP 400; unknown upgrade paths return HTTP 404. Never put tokens in URLs.
Sybil adds its private Pi credential only to the upstream Authorization header; Sybil adds its private Sybil Agent credential only to the upstream Authorization header;
browser credentials are not forwarded to Pi. browser credentials are not forwarded to the agent.
Native `pi-room.v1` frames are forwarded after authentication: Native `pi-room.v1` frames are forwarded after authentication. This protocol
identifier is retained for compatibility with existing clients:
```json ```json
{ "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." } { "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." }
@@ -317,7 +318,7 @@ Native `pi-room.v1` frames are forwarded after authentication:
{ "type": "activity", "conversationId": "1", "activity": { "state": "thinking", "isThinking": true, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": [] } } { "type": "activity", "conversationId": "1", "activity": { "state": "thinking", "isThinking": true, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": [] } }
``` ```
Activity is forwarded from Pi and also describes tools, queued messages, durable Activity is forwarded from Sybil Agent and also describes tools, queued messages, durable
waiting, errors, and workers. Use `isThinking` for the thinking indicator and waiting, errors, and workers. Use `isThinking` for the thinking indicator and
`backgroundWorkers` for ongoing delegated work; no model reasoning is exposed. Tools arrive as `role: "tool"` messages `backgroundWorkers` for ongoing delegated work; no model reasoning is exposed. Tools arrive as `role: "tool"` messages
with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and
@@ -329,8 +330,8 @@ Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
ping/pong heartbeats detect dead connections. Other writes belong on REST. ping/pong heartbeats detect dead connections. Other writes belong on REST.
Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011 Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011
or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged
cursor. Missing Pi configuration closes with 4503. cursor. Missing agent configuration closes with 4503.
The proxy closes upstream sockets when browsers leave or Sybil shuts down. The proxy closes upstream sockets when browsers leave or Sybil shuts down.
History remains durable in Pi. Shared read position is separate from delivery: History remains durable in Sybil Agent. Shared read position is separate from delivery:
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed. update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
+9 -8
View File
@@ -107,23 +107,24 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
- Postgres support + migrations for prod - Postgres support + migrations for prod
- Attachments + embeddings + semantic search - Attachments + embeddings + semantic search
## Persistent Pi Agent ## Sybil Agent
Configure the backend with: Configure the backend with:
```dotenv ```dotenv
PI_AGENT_BASE_URL=http://pi-durable:8788 SYBIL_AGENT_BASE_URL=http://sybil-agent:8788
PI_AGENT_API_KEY=<the Pi service's PI_API_KEY> SYBIL_AGENT_API_KEY=<the Sybil Agent service API key>
``` ```
The base URL defaults to `http://pi-durable:8788` and has no `/v1` suffix. In Docker, Use the same `SYBIL_AGENT_API_KEY` value configured on the agent service.
attach Sybil's **server** container and Pi to the shared `agents` network so that The base URL defaults to `http://sybil-agent:8788` and has no `/v1` suffix. In Docker,
`pi-durable` resolves privately. Supply the key through a private server environment attach Sybil's **server** container and Sybil Agent to the shared `agents` network so that
`sybil-agent` resolves privately. Supply the key through a private server environment
file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil
`ADMIN_TOKEN` authentication for clients. `ADMIN_TOKEN` authentication for clients.
The Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the The Sybil Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the
same persistent Pi root conversation across devices. Pi remains the source of same persistent root conversation across devices. Sybil Agent remains the source of
truth for messages and background replies. Sybil stores only the shared, truth for messages and background replies. Sybil stores only the shared,
monotonically increasing read cursor in SQLite through `/v1/agent/read-state`; monotonically increasing read cursor in SQLite through `/v1/agent/read-state`;
its migration is applied automatically at startup. The bundled nginx configuration its migration is applied automatically at startup. The bundled nginx configuration
+2 -2
View File
@@ -216,8 +216,8 @@ model ProjectItem {
@@index([searchId]) @@index([searchId])
} }
// Shared personal read position for the external persistent Pi room. // Shared personal read position for the external persistent Sybil Agent room.
// Messages remain in Pi's durable transcript, not duplicated in Sybil Chat. // Messages remain in the agent's durable transcript, not duplicated in Sybil Chat.
model AgentReadState { model AgentReadState {
id String @id id String @id
cursor String @default("0") cursor String @default("0")
+12 -12
View File
@@ -30,22 +30,22 @@ async function readState() {
return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null }; return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null };
} }
/** Pi owns the conversation; Sybil stores only its shared personal read cursor. */ /** Sybil Agent owns the conversation; the chat server stores only its shared read cursor. */
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) { export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
const baseUrl = options.baseUrl || env.PI_AGENT_BASE_URL; const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
const apiKey = options.apiKey ?? env.PI_AGENT_API_KEY; const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
const proxyJson = async (path: string, method = 'GET', body?: unknown) => { const proxyJson = async (path: string, method = 'GET', body?: unknown) => {
if (!apiKey) throw app.httpErrors.serviceUnavailable('Pi agent is not configured'); if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
let response: Response; let response: Response;
try { try {
response = await fetch(new URL(path, baseUrl), { method, response = await fetch(new URL(path, baseUrl), { method,
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000), ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000),
}); });
} catch { throw app.httpErrors.badGateway('Pi agent is unavailable'); } } catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
let data: unknown; let data: unknown;
try { data = await response.json(); } try { data = await response.json(); }
catch { throw app.httpErrors.badGateway('Pi agent returned invalid JSON'); } catch { throw app.httpErrors.badGateway('Sybil Agent returned invalid JSON'); }
return { status: response.status, data }; return { status: response.status, data };
}; };
@@ -75,7 +75,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
const receipt = SubmissionReceipt.safeParse(result.data); const receipt = SubmissionReceipt.safeParse(result.data);
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) { if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) {
throw app.httpErrors.badGateway('Pi agent returned an invalid message receipt'); throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt');
} }
} }
reply.code(result.status); reply.code(result.status);
@@ -137,22 +137,22 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: strin
if (!authenticated) { if (!authenticated) {
const auth = parse(AuthFrame, frame); const auth = parse(AuthFrame, frame);
if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; } if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; }
if (!apiKey) { browser.close(4503, 'Pi agent is not configured'); return; } if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; }
authenticated = true; clearTimeout(authTimeout); authenticated = true; clearTimeout(authTimeout);
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:'; const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 }); upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) }))); upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
upstream.on('message', (data, binary) => { upstream.on('message', (data, binary) => {
if (binary) { browser.close(1011, 'Invalid Pi frame'); return; } if (binary) { browser.close(1011, 'Invalid Sybil Agent frame'); return; }
send(data.toString()); send(data.toString());
}); });
upstream.on('close', () => { if (!disposed) browser.close(1012, 'Pi agent disconnected; reconnect with cursor'); }); upstream.on('close', () => { if (!disposed) browser.close(1012, 'Sybil Agent disconnected; reconnect with cursor'); });
upstream.on('error', () => { send({ type: 'error', error: 'Pi agent is unavailable' }); browser.close(1011, 'Pi agent is unavailable'); }); upstream.on('error', () => { send({ type: 'error', error: 'Sybil Agent is unavailable' }); browser.close(1011, 'Sybil Agent is unavailable'); });
return; return;
} }
if (frame.type === 'ping') { send({ type: 'pong' }); return; } if (frame.type === 'ping') { send({ type: 'pong' }); return; }
const subscribe = parse(SubscribeFrame, frame); const subscribe = parse(SubscribeFrame, frame);
if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Pi agent is still connecting' }); return; } if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Sybil Agent is still connecting' }); return; }
upstream.send(JSON.stringify(subscribe)); upstream.send(JSON.stringify(subscribe));
} catch (error) { } catch (error) {
if (!authenticated) browser.close(4400, 'Invalid authentication frame'); if (!authenticated) browser.close(4400, 'Invalid authentication frame');
+2 -2
View File
@@ -70,8 +70,8 @@ const EnvSchema = z.object({
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema, HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema, HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema, HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
PI_AGENT_BASE_URL: z.string().url().default("http://pi-durable:8788"), SYBIL_AGENT_BASE_URL: z.string().url().default("http://sybil-agent:8788"),
PI_AGENT_API_KEY: OptionalTrimmedStringSchema, SYBIL_AGENT_API_KEY: OptionalTrimmedStringSchema,
EXA_API_KEY: z.string().optional(), EXA_API_KEY: z.string().optional(),
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema, BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
+6 -6
View File
@@ -23,7 +23,7 @@ const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([
import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'), import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'),
]); ]);
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }; const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` };
const privateKey = 'pi-private-key-for-test'; const privateKey = 'sybil-agent-private-key-for-test';
const requests: { method?: string; url?: string; body: any }[] = []; const requests: { method?: string; url?: string; body: any }[] = [];
const accepted = new Map<string, number>(); const accepted = new Map<string, number>();
let upstreamReply: { status: number; body: string } | undefined; let upstreamReply: { status: number; body: string } | undefined;
@@ -42,7 +42,7 @@ const upstream = createServer(async (req, res) => {
res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` })); res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` }));
} else if (req.url?.includes('/messages')) { } else if (req.url?.includes('/messages')) {
res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false })); res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false }));
} else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Pi', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } })); } else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Sybil Agent', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } }));
}); });
const sourceSockets = new WebSocketServer({ noServer: true }); const sourceSockets = new WebSocketServer({ noServer: true });
upstream.on('upgrade', (req, socket, head) => { upstream.on('upgrade', (req, socket, head) => {
@@ -80,7 +80,7 @@ async function eventually(check: () => boolean) {
assert.fail('Expected condition was not reached'); assert.fail('Expected condition was not reached');
} }
test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Pi credentials', async () => { test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Sybil Agent credentials', async () => {
assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401); assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401);
const descriptor = await app.inject({ url: '/v1/agent', headers: auth }); const descriptor = await app.inject({ url: '/v1/agent', headers: auth });
assert.equal(descriptor.statusCode, 200); assert.equal(descriptor.statusCode, 200);
@@ -94,7 +94,7 @@ test('REST authenticates, proxies the shared room, and retains stable message ID
const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input }); const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json()); assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json());
assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false }); assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
assert.equal((await prisma.chat.count()), 0, 'Pi messages are not copied into ordinary Sybil chats'); assert.equal((await prisma.chat.count()), 0, 'Sybil Agent messages are not copied into ordinary Sybil chats');
assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400); assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400);
assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400); assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400);
}); });
@@ -132,7 +132,7 @@ test('malformed upstream JSON and unconfirmed message receipts return 502, prese
assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`); assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`);
}); });
test('malformed and unmatched upgrade targets close without crashing or opening Pi connections', async () => { test('malformed and unmatched upgrade targets close without crashing or opening Sybil Agent connections', async () => {
const before = connections; const before = connections;
const requestUpgrade = (target: string): Promise<string> => new Promise((resolve, reject) => { const requestUpgrade = (target: string): Promise<string> => new Promise((resolve, reject) => {
let response = ''; let response = '';
@@ -172,7 +172,7 @@ test('WebSocket first-frame authentication gates the private connection and forw
await eventually(() => sourceSockets.clients.size === 0); await eventually(() => sourceSockets.clients.size === 0);
}); });
test('open-mode WebSocket authentication accepts null and still keeps the Pi token server-side', async () => { test('open-mode WebSocket authentication accepts null and still keeps the Sybil Agent token server-side', async () => {
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined; const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
try { try {
const browser = new WebSocket(wsUrl); const frames: any[] = []; const browser = new WebSocket(wsUrl); const frames: any[] = [];
+1 -1
View File
@@ -26,7 +26,7 @@ const entryId = (message: Message): bigint | undefined => {
const toolId = (message: Message) => typeof data(message).toolCallId === 'string' ? String(data(message).toolCallId) : message.id; const toolId = (message: Message) => typeof data(message).toolCallId === 'string' ? String(data(message).toolCallId) : message.id;
/** /**
* Pi sends messages and activity as separate frames. This adapter publishes them * Sybil Agent sends messages and activity as separate frames. This adapter publishes them
* as one room snapshot and latches transcript typing off when a new public reply * as one room snapshot and latches transcript typing off when a new public reply
* arrives. An unchanged busy frame cannot restore the old turn's indicator. * arrives. An unchanged busy frame cannot restore the old turn's indicator.
* Actual activity remains untouched for the sidebar, workers, and queue display. * Actual activity remains untouched for the sidebar, workers, and queue display.
+2 -2
View File
@@ -9,7 +9,7 @@ import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.
const message = (id, role = "assistant", extra = {}) => ({ const message = (id, role = "assistant", extra = {}) => ({
id: `entry:${id}`, createdAt: `2026-10-14T10:00:${String(id % 60).padStart(2, "0")}.000Z`, id: `entry:${id}`, createdAt: `2026-10-14T10:00:${String(id % 60).padStart(2, "0")}.000Z`,
role, content: `message ${id}`, name: role === "assistant" ? "Pi" : "buzzert", role, content: `message ${id}`, name: role === "assistant" ? "Sybil Agent" : "buzzert",
metadata: { kind: "message", entryId: id, status: "complete" }, ...extra, metadata: { kind: "message", entryId: id, status: "complete" }, ...extra,
}); });
const page = (cursor, messages = [], hasMore = false) => ({ const page = (cursor, messages = [], hasMore = false) => ({
@@ -183,7 +183,7 @@ test("malformed successful receipts keep optimistic messages retryable under the
assert.equal(row.metadata.status, "failed"); assert.equal(row.metadata.status, "failed");
assert.equal(box.snapshot()[0].seenOnServer, false); assert.equal(box.snapshot()[0].seenOnServer, false);
const valid = { id: 1, status: "queued", messageId: row.id }; const valid = { id: 1, status: "queued", messageId: row.id };
for (const malformed of [null, { error: "Invalid Pi response" }, { ...valid, id: 0 }, { ...valid, id: 1.5 }, for (const malformed of [null, { error: "Invalid Sybil Agent response" }, { ...valid, id: 0 }, { ...valid, id: 1.5 },
{ ...valid, id: Number.MAX_SAFE_INTEGER + 1 }, { ...valid, status: "unknown" }, { ...valid, status: ["queued"] }, { ...valid, id: Number.MAX_SAFE_INTEGER + 1 }, { ...valid, status: "unknown" }, { ...valid, status: ["queued"] },
{ ...valid, messageId: "inbound:sybil:other" }]) { { ...valid, messageId: "inbound:sybil:other" }]) {
response = malformed; response = malformed;