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:
+10
-10
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user