Add persistent agent messaging and shared chat bubbles
Connect Sybil to the Pi root conversation with authenticated REST and WebSocket APIs, persistent read state, and a dedicated agent tab. Keep optimistic messages stable through delivery and retries, and animate arrivals without losing the reader's scroll position. Share bubble styling with regular chats and remove the obsolete admission sender. Reject malformed upstream receipts and WebSocket upgrade requests, with regression coverage and updated API documentation. Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile bubble comparisons, and 11 browser animation and scrolling checks.
This commit is contained in:
Vendored
+7
-1
@@ -1,3 +1,8 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -19,7 +24,8 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
|
||||
@@ -562,3 +562,73 @@ Behavior notes:
|
||||
```
|
||||
|
||||
For streaming contracts, see `docs/api/streaming-chat.md`.
|
||||
|
||||
## Persistent Agent Room
|
||||
|
||||
The Agent tab connects to one persistent Pi root conversation. Pi owns its messages,
|
||||
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
|
||||
REST authentication. The server connects privately to `PI_AGENT_BASE_URL` (default
|
||||
`http://pi-durable:8788`) using `PI_AGENT_API_KEY`. The Pi key never reaches browsers.
|
||||
|
||||
### `GET /v1/agent`
|
||||
|
||||
Returns the Pi room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`,
|
||||
`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
|
||||
configuration returns 503; an unreachable Pi server or malformed upstream JSON
|
||||
returns 502.
|
||||
|
||||
### `GET /v1/agent/messages`
|
||||
|
||||
Query parameters: optional `after` or `before` (mutually exclusive nonnegative
|
||||
numeric cursor strings), and `limit` (1–200). Without a cursor, returns recent
|
||||
history. `after` catches up forward; `before` loads earlier history.
|
||||
|
||||
```json
|
||||
{
|
||||
"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" } }],
|
||||
"cursor": "43",
|
||||
"beforeCursor": "43",
|
||||
"hasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
Messages use `user`, `assistant`, and `tool` roles. Tools use
|
||||
`metadata.kind: "tool_call"` and stable IDs; merge by message `id`, since later
|
||||
pages can update initiated tools to completed or failed. Internal worker reports,
|
||||
event prompts, and model reasoning are excluded. Cursors refer to underlying
|
||||
durable entries and can advance without visible messages. Use returned cursors;
|
||||
do not derive them from message IDs. `limit` bounds source entries, not displayed
|
||||
messages.
|
||||
|
||||
### `POST /v1/agent/messages`
|
||||
|
||||
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
||||
Content must contain non-whitespace text and is limited to 40,000 characters;
|
||||
`requestId` is required (1–256 characters). Preserve the ID when retrying uncertain
|
||||
network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
|
||||
`wait: false`.
|
||||
|
||||
Returns Pi's `202 Accepted` receipt, including `id` (submission ID), `status`, and
|
||||
`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not
|
||||
completion: watch the room for answers, including unsolicited later replies.
|
||||
Multiple requests can be accepted while Pi works. Attachments are not yet supported.
|
||||
The proxy verifies successful receipts contain a positive submission ID, a known
|
||||
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`.
|
||||
Invalid receipts return 502; delivery remains uncertain, so retry using the original
|
||||
`requestId` to avoid creating a duplicate message.
|
||||
|
||||
### `GET /v1/agent/read-state`
|
||||
### `PUT /v1/agent/read-state`
|
||||
|
||||
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z" }`.
|
||||
Initially: `{ "cursor": "0", "updatedAt": null }`. PUT takes
|
||||
`{ "cursor": "43" }` and returns the same shape. The cursor is shared across this
|
||||
personal installation and persisted in SQLite. Atomic, monotonic updates prevent
|
||||
a stale browser from lowering a cursor saved by another browser. Advance it only
|
||||
after showing the corresponding history; poll it to reconcile unread indicators
|
||||
across clients. Agent REST responses use `Cache-Control: no-store`.
|
||||
|
||||
See the persistent Agent WebSocket section in `streaming-chat.md` for live updates.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Streaming Chat API Contract
|
||||
|
||||
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions.
|
||||
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions, and the WebSocket contract for the persistent Agent room.
|
||||
|
||||
Endpoint:
|
||||
- `POST /v1/chat-completions/stream`
|
||||
@@ -275,3 +275,62 @@ event: done
|
||||
data: {"type":"done","text":"Hello world"}
|
||||
|
||||
```
|
||||
|
||||
## Persistent Agent WebSocket
|
||||
|
||||
`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
|
||||
connected after each assistant turn and receives later background results. Sending
|
||||
messages uses `POST /v1/agent/messages`.
|
||||
|
||||
The browser must send this first frame within five seconds:
|
||||
|
||||
```json
|
||||
{ "type": "auth", "token": "Sybil token", "after": "43" }
|
||||
```
|
||||
|
||||
In open mode, use `token: null`. Omit `after` for recent history; on reconnect supply
|
||||
the last successfully merged cursor. Invalid credentials close with 4401, malformed
|
||||
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.
|
||||
Sybil adds its private Pi credential only to the upstream Authorization header;
|
||||
browser credentials are not forwarded to Pi.
|
||||
|
||||
Native `pi-room.v1` frames are forwarded after authentication:
|
||||
|
||||
```json
|
||||
{ "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "messages",
|
||||
"conversationId": "1",
|
||||
"messages": [{ "id": "outbound:44", "role": "assistant", "content": "The result is ready.", "createdAt": "2026-10-14T18:00:00.000Z", "metadata": { "kind": "message", "entryId": 44, "status": "complete" } }],
|
||||
"cursor": "44",
|
||||
"beforeCursor": "44",
|
||||
"hasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "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
|
||||
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
|
||||
with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and
|
||||
socket messages are upserts keyed by stable `id`. Save each returned cursor after
|
||||
applying its entire frame, even when it contains no visible messages.
|
||||
|
||||
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
|
||||
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
|
||||
ping/pong heartbeats detect dead connections. Other writes belong on REST.
|
||||
Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011
|
||||
or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged
|
||||
cursor. Missing Pi configuration closes with 4503.
|
||||
|
||||
The proxy closes upstream sockets when browsers leave or Sybil shuts down.
|
||||
History remains durable in Pi. Shared read position is separate from delivery:
|
||||
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
|
||||
|
||||
@@ -106,3 +106,26 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
|
||||
- User accounts + per-device API keys
|
||||
- Postgres support + migrations for prod
|
||||
- Attachments + embeddings + semantic search
|
||||
|
||||
## Persistent Pi Agent
|
||||
|
||||
Configure the backend with:
|
||||
|
||||
```dotenv
|
||||
PI_AGENT_BASE_URL=http://pi-durable:8788
|
||||
PI_AGENT_API_KEY=<the Pi service's PI_API_KEY>
|
||||
```
|
||||
|
||||
The base URL defaults to `http://pi-durable:8788` and has no `/v1` suffix. In Docker,
|
||||
attach Sybil's **server** container and Pi to the shared `agents` network so that
|
||||
`pi-durable` resolves privately. Supply the key through a private server environment
|
||||
file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil
|
||||
`ADMIN_TOKEN` authentication for clients.
|
||||
|
||||
The 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
|
||||
truth for messages and background replies. Sybil stores only the shared,
|
||||
monotonically increasing read cursor in SQLite through `/v1/agent/read-state`;
|
||||
its migration is applied automatically at startup. The bundled nginx configuration
|
||||
forwards WebSocket upgrades. See `docs/api/rest.md` and
|
||||
`docs/api/streaming-chat.md` at the repository root for the client contracts.
|
||||
|
||||
Generated
+33
@@ -22,10 +22,12 @@
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
@@ -876,6 +878,16 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@@ -2241,6 +2253,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
|
||||
@@ -31,10 +31,12 @@
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE "AgentReadState" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"cursor" TEXT NOT NULL DEFAULT '0',
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
@@ -215,3 +215,11 @@ model ProjectItem {
|
||||
@@index([chatId])
|
||||
@@index([searchId])
|
||||
}
|
||||
|
||||
// Shared personal read position for the external persistent Pi room.
|
||||
// Messages remain in Pi's durable transcript, not duplicated in Sybil Chat.
|
||||
model AgentReadState {
|
||||
id String @id
|
||||
cursor String @default("0")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from './auth.js';
|
||||
import { prisma } from './db.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor')
|
||||
.transform(value => String(Number(value)));
|
||||
const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() })
|
||||
.refine(value => !(value.after && value.before), 'Use after or before, not both');
|
||||
const MessageBody = z.object({ content: z.string().min(1).max(40_000).refine(value => value.trim().length > 0, 'content is required'), requestId: z.string().min(1).max(256) });
|
||||
const ReadBody = z.object({ cursor: Cursor });
|
||||
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() });
|
||||
const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() });
|
||||
const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
||||
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string() });
|
||||
|
||||
type AgentOptions = { baseUrl?: string; apiKey?: string };
|
||||
const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) throw Object.assign(new Error(result.error.issues.map(issue => issue.message).join('; ')), { statusCode: 400 });
|
||||
return result.data;
|
||||
};
|
||||
|
||||
async function readState() {
|
||||
const value = await prisma.agentReadState.findUnique({ where: { id: 'root' } });
|
||||
return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null };
|
||||
}
|
||||
|
||||
/** Pi owns the conversation; Sybil stores only its shared personal read cursor. */
|
||||
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
|
||||
const baseUrl = options.baseUrl || env.PI_AGENT_BASE_URL;
|
||||
const apiKey = options.apiKey ?? env.PI_AGENT_API_KEY;
|
||||
const proxyJson = async (path: string, method = 'GET', body?: unknown) => {
|
||||
if (!apiKey) throw app.httpErrors.serviceUnavailable('Pi agent is not configured');
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL(path, baseUrl), { method,
|
||||
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch { throw app.httpErrors.badGateway('Pi agent is unavailable'); }
|
||||
let data: unknown;
|
||||
try { data = await response.json(); }
|
||||
catch { throw app.httpErrors.badGateway('Pi agent returned invalid JSON'); }
|
||||
return { status: response.status, data };
|
||||
};
|
||||
|
||||
app.get('/v1/agent', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const result = await proxyJson('/api/conversations/root');
|
||||
reply.code(result.status);
|
||||
if (result.status !== 200) return result.data;
|
||||
return { ...result.data as object, websocket: '/v1/agent/ws' };
|
||||
});
|
||||
app.get('/v1/agent/messages', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const query = parse(PageQuery, req.query);
|
||||
const params = new URLSearchParams();
|
||||
for (const [name, value] of Object.entries(query)) if (value !== undefined) params.set(name, String(value));
|
||||
const result = await proxyJson(`/api/conversations/root/messages?${params}`);
|
||||
reply.code(result.status);
|
||||
return result.data;
|
||||
});
|
||||
app.post('/v1/agent/messages', { bodyLimit: 256 * 1024 }, async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const body = parse(MessageBody, req.body);
|
||||
const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
const receipt = SubmissionReceipt.safeParse(result.data);
|
||||
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) {
|
||||
throw app.httpErrors.badGateway('Pi agent returned an invalid message receipt');
|
||||
}
|
||||
}
|
||||
reply.code(result.status);
|
||||
return result.data;
|
||||
});
|
||||
app.get('/v1/agent/read-state', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
return readState();
|
||||
});
|
||||
app.put('/v1/agent/read-state', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const { cursor } = parse(ReadBody, req.body);
|
||||
// One SQLite statement makes concurrent tabs monotonic. Never write an older
|
||||
// cursor over one saved by another connection between a read and an update.
|
||||
await prisma.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT("id") DO UPDATE SET
|
||||
"cursor" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN excluded."cursor" ELSE "AgentReadState"."cursor" END,
|
||||
"updatedAt" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN CURRENT_TIMESTAMP ELSE "AgentReadState"."updatedAt" END`;
|
||||
return readState();
|
||||
});
|
||||
|
||||
attachAgentSocket(app, baseUrl, apiKey);
|
||||
}
|
||||
|
||||
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: string) {
|
||||
const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
|
||||
const cleanup = new Set<() => void>();
|
||||
const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||
let url: URL;
|
||||
try { url = new URL(request.url || '/', 'http://localhost'); }
|
||||
catch { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
|
||||
if (url.pathname !== '/v1/agent/ws') { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); return; }
|
||||
// Credentials must never enter URLs and access logs.
|
||||
if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
|
||||
sockets.handleUpgrade(request, socket, head, browser => {
|
||||
let upstream: WebSocket | undefined;
|
||||
let authenticated = false, disposed = false, alive = true;
|
||||
const send = (value: unknown) => {
|
||||
if (browser.readyState !== WebSocket.OPEN) return;
|
||||
if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; }
|
||||
browser.send(typeof value === 'string' ? value : JSON.stringify(value));
|
||||
};
|
||||
const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000);
|
||||
const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000);
|
||||
browser.on('pong', () => { alive = true; });
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose);
|
||||
upstream?.terminate(); browser.terminate();
|
||||
};
|
||||
cleanup.add(dispose);
|
||||
browser.on('close', dispose); browser.on('error', dispose);
|
||||
browser.on('message', (raw, isBinary) => {
|
||||
try {
|
||||
if (isBinary) throw new Error('JSON text frames are required');
|
||||
const frame = JSON.parse(raw.toString());
|
||||
if (!authenticated) {
|
||||
const auth = parse(AuthFrame, frame);
|
||||
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; }
|
||||
authenticated = true; clearTimeout(authTimeout);
|
||||
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.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
|
||||
upstream.on('message', (data, binary) => {
|
||||
if (binary) { browser.close(1011, 'Invalid Pi frame'); return; }
|
||||
send(data.toString());
|
||||
});
|
||||
upstream.on('close', () => { if (!disposed) browser.close(1012, 'Pi agent disconnected; reconnect with cursor'); });
|
||||
upstream.on('error', () => { send({ type: 'error', error: 'Pi agent is unavailable' }); browser.close(1011, 'Pi agent is unavailable'); });
|
||||
return;
|
||||
}
|
||||
if (frame.type === 'ping') { send({ type: 'pong' }); return; }
|
||||
const subscribe = parse(SubscribeFrame, frame);
|
||||
if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Pi agent is still connecting' }); return; }
|
||||
upstream.send(JSON.stringify(subscribe));
|
||||
} catch (error) {
|
||||
if (!authenticated) browser.close(4400, 'Invalid authentication frame');
|
||||
else send({ type: 'error', error: error instanceof Error ? error.message : 'Invalid frame' });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
app.server.on('upgrade', upgrade);
|
||||
app.addHook('preClose', async () => {
|
||||
app.server.off('upgrade', upgrade);
|
||||
for (const stop of cleanup) stop();
|
||||
for (const client of sockets.clients) client.terminate();
|
||||
sockets.close();
|
||||
});
|
||||
}
|
||||
@@ -70,6 +70,8 @@ const EnvSchema = z.object({
|
||||
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
||||
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||
PI_AGENT_BASE_URL: z.string().url().default("http://pi-durable:8788"),
|
||||
PI_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||
EXA_API_KEY: z.string().optional(),
|
||||
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
|
||||
|
||||
|
||||
+3
-1
@@ -6,6 +6,7 @@ import sensible from "@fastify/sensible";
|
||||
import { env } from "./env.js";
|
||||
import { ensureDatabaseReady } from "./db-init.js";
|
||||
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
|
||||
import { registerAgentRoutes } from "./agent.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
const app = Fastify({
|
||||
@@ -26,7 +27,7 @@ const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ["GET", "HEAD", "POST", "DELETE", "OPTIONS"],
|
||||
methods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
});
|
||||
|
||||
await app.register(swagger, {
|
||||
@@ -86,6 +87,7 @@ app.addHook("onClose", async () => {
|
||||
});
|
||||
|
||||
await registerRoutes(app);
|
||||
await registerAgentRoutes(app);
|
||||
|
||||
await app.listen({ port: env.PORT, host: env.HOST });
|
||||
app.log.info(`listening on http://${env.HOST}:${env.PORT}`);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { createConnection } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Fastify from 'fastify';
|
||||
import sensible from '@fastify/sensible';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-'));
|
||||
process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`;
|
||||
process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789';
|
||||
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], {
|
||||
cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe',
|
||||
});
|
||||
const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([
|
||||
import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'),
|
||||
]);
|
||||
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` };
|
||||
const privateKey = 'pi-private-key-for-test';
|
||||
const requests: { method?: string; url?: string; body: any }[] = [];
|
||||
const accepted = new Map<string, number>();
|
||||
let upstreamReply: { status: number; body: string } | undefined;
|
||||
let connections = 0;
|
||||
const subscriptions: any[] = [];
|
||||
const upstream = createServer(async (req, res) => {
|
||||
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
|
||||
requests.push({ method: req.method, url: req.url, body });
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (upstreamReply) { res.statusCode = upstreamReply.status; res.end(upstreamReply.body); return; }
|
||||
if (req.method === 'POST') {
|
||||
const id = accepted.get(body.requestId) || accepted.size + 1; accepted.set(body.requestId, id);
|
||||
res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` }));
|
||||
} 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 }));
|
||||
} else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Pi', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } }));
|
||||
});
|
||||
const sourceSockets = new WebSocketServer({ noServer: true });
|
||||
upstream.on('upgrade', (req, socket, head) => {
|
||||
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
|
||||
assert.equal(req.url, '/api/ws');
|
||||
connections++;
|
||||
sourceSockets.handleUpgrade(req, socket, head, ws => {
|
||||
ws.on('message', raw => {
|
||||
const frame = JSON.parse(raw.toString()); subscriptions.push(frame);
|
||||
ws.send(JSON.stringify({ type: 'ready', protocol: 'pi-room.v1', conversationId: '1' }));
|
||||
ws.send(JSON.stringify({ type: 'messages', conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', hasMore: false }));
|
||||
ws.send(JSON.stringify({ type: 'activity', conversationId: '1', activity: { thinking: true, responding: true } }));
|
||||
});
|
||||
});
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening');
|
||||
const upstreamAddress = upstream.address() as { port: number };
|
||||
const app = Fastify({ logger: false }); await app.register(sensible);
|
||||
await registerAgentRoutes(app, { baseUrl: `http://127.0.0.1:${upstreamAddress.port}`, apiKey: privateKey });
|
||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
const address = app.server.address() as { port: number };
|
||||
const wsUrl = `ws://127.0.0.1:${address.port}/v1/agent/ws`;
|
||||
|
||||
test.after(async () => {
|
||||
await app.close();
|
||||
for (const socket of sourceSockets.clients) socket.terminate();
|
||||
sourceSockets.close();
|
||||
await new Promise<void>(resolve => upstream.close(() => resolve()));
|
||||
await prisma.$disconnect();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function eventually(check: () => boolean) {
|
||||
for (let i = 0; i < 100; i++) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 10)); }
|
||||
assert.fail('Expected condition was not reached');
|
||||
}
|
||||
|
||||
test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Pi credentials', async () => {
|
||||
assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401);
|
||||
const descriptor = await app.inject({ url: '/v1/agent', headers: auth });
|
||||
assert.equal(descriptor.statusCode, 200);
|
||||
assert.equal(descriptor.json().websocket, '/v1/agent/ws');
|
||||
assert.ok(!descriptor.body.includes(privateKey));
|
||||
const page = await app.inject({ url: '/v1/agent/messages?after=42&limit=20', headers: auth });
|
||||
assert.equal(page.json().messages[0].role, 'assistant');
|
||||
assert.match(requests.at(-1)!.url!, /after=42&limit=20/);
|
||||
const input = { content: ' Keep watching\n', requestId: 'same-client-message' };
|
||||
const first = 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.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 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);
|
||||
});
|
||||
|
||||
test('the read cursor stays monotonic under concurrent tabs and survives reconnecting to SQLite', async () => {
|
||||
const initial = await app.inject({ url: '/v1/agent/read-state', headers: auth });
|
||||
assert.equal(initial.json().cursor, '0');
|
||||
assert.equal(initial.headers['cache-control'], 'no-store');
|
||||
await Promise.all(['900', '4', '100', '12'].map(cursor => app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor } })));
|
||||
assert.equal((await app.inject({ url: '/v1/agent/read-state', headers: auth })).json().cursor, '900');
|
||||
await prisma.$disconnect();
|
||||
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '5' } })).json().cursor, '900');
|
||||
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '-1' } })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('malformed upstream JSON and unconfirmed message receipts return 502, preserving safe retries', async () => {
|
||||
const payload = { content: 'One durable message', requestId: 'invalid-receipt-retry' };
|
||||
try {
|
||||
upstreamReply = { status: 200, body: '{truncated' };
|
||||
for (const url of ['/v1/agent', '/v1/agent/messages']) {
|
||||
const response = await app.inject({ url, headers: auth });
|
||||
assert.equal(response.statusCode, 502);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
for (const body of ['{truncated', '{}', 'null', JSON.stringify({ id: 1, status: 'queued', messageId: 'wrong-message' }),
|
||||
JSON.stringify({ id: 1, status: 'unknown', messageId: `inbound:sybil:${payload.requestId}` })]) {
|
||||
upstreamReply = { status: 202, body };
|
||||
const response = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
|
||||
assert.equal(response.statusCode, 502, body);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
} finally { upstreamReply = undefined; }
|
||||
const retry = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
|
||||
assert.equal(retry.statusCode, 202);
|
||||
assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`);
|
||||
});
|
||||
|
||||
test('malformed and unmatched upgrade targets close without crashing or opening Pi connections', async () => {
|
||||
const before = connections;
|
||||
const requestUpgrade = (target: string): Promise<string> => new Promise((resolve, reject) => {
|
||||
let response = '';
|
||||
const socket = createConnection({ host: '127.0.0.1', port: address.port }, () => {
|
||||
socket.write(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`);
|
||||
});
|
||||
socket.setTimeout(1000, () => socket.destroy(new Error('Upgrade rejection did not close the socket')));
|
||||
socket.on('data', chunk => { response += chunk.toString(); });
|
||||
socket.on('error', reject);
|
||||
socket.on('end', () => { socket.destroy(); resolve(response); });
|
||||
});
|
||||
assert.match(await requestUpgrade('http://['), /^HTTP\/1\.1 400 /);
|
||||
assert.match(await requestUpgrade('/v1/not-an-agent/ws'), /^HTTP\/1\.1 404 /);
|
||||
assert.match(await requestUpgrade('/v1/agent/ws?token=forbidden'), /^HTTP\/1\.1 400 /);
|
||||
assert.equal(connections, before);
|
||||
assert.equal((await app.inject({ url: '/v1/agent', headers: auth })).statusCode, 200);
|
||||
});
|
||||
|
||||
test('WebSocket first-frame authentication gates the private connection and forwards resumable native frames', async () => {
|
||||
const before = connections;
|
||||
const denied = new WebSocket(wsUrl); await once(denied, 'open');
|
||||
denied.send(JSON.stringify({ type: 'auth', token: 'wrong' }));
|
||||
const [code] = await once(denied, 'close');
|
||||
assert.equal(code, 4401); assert.equal(connections, before);
|
||||
const browser = new WebSocket(wsUrl);
|
||||
const frames: any[] = []; browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
|
||||
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: env.ADMIN_TOKEN, after: '42' }));
|
||||
await eventually(() => frames.some(frame => frame.type === 'activity'));
|
||||
assert.deepEqual(subscriptions.at(-1), { type: 'subscribe', after: '42' });
|
||||
assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']);
|
||||
assert.equal(frames[1].messages[0].content, 'Later result');
|
||||
assert.equal(frames[2].activity.thinking, true);
|
||||
assert.ok(!JSON.stringify(frames).includes(privateKey));
|
||||
browser.send(JSON.stringify({ type: 'subscribe', after: '43' }));
|
||||
await eventually(() => subscriptions.at(-1)?.after === '43');
|
||||
browser.close(); await once(browser, 'close');
|
||||
await eventually(() => sourceSockets.clients.size === 0);
|
||||
});
|
||||
|
||||
test('open-mode WebSocket authentication accepts null and still keeps the Pi token server-side', async () => {
|
||||
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
|
||||
try {
|
||||
const browser = new WebSocket(wsUrl); const frames: any[] = [];
|
||||
browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
|
||||
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: null, after: '0' }));
|
||||
await eventually(() => frames.some(frame => frame.type === 'ready'));
|
||||
browser.close(); await once(browser, 'close');
|
||||
} finally { env.ADMIN_TOKEN = saved; }
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
+61
-10
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/ho
|
||||
import type { TargetedTouchEvent } from "preact";
|
||||
import {
|
||||
Check,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
GitFork,
|
||||
Globe2,
|
||||
@@ -25,6 +26,7 @@ import { AuthScreen } from "@/components/auth/auth-screen";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { ChatComposer } from "@/components/chat/chat-composer";
|
||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||
import { AgentConversation } from "@/components/agent/agent-conversation";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import { SearchMatchLabel } from "@/components/search/search-match-label";
|
||||
import { SybilCharacter } from "@/components/sybil-character";
|
||||
@@ -67,6 +69,7 @@ import {
|
||||
type WorkspaceItem,
|
||||
} from "@/lib/api";
|
||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||
import { useAgentRoom } from "@/hooks/use-agent-room";
|
||||
import { useWorkspaceSearch } from "@/hooks/use-workspace-search";
|
||||
import {
|
||||
getChatModelSelection,
|
||||
@@ -81,6 +84,7 @@ import {
|
||||
retainSelectedHistoryItem,
|
||||
resolveSidebarSelectionAfterRefresh,
|
||||
type SidebarSelection,
|
||||
type HistorySelection,
|
||||
} from "@/lib/sidebar-selection";
|
||||
import { buildQuickQuestionRequest, pickQuickQuestionModel, QUICK_QUESTION_DEFAULT_MODEL } from "@/lib/quick-question";
|
||||
import {
|
||||
@@ -96,7 +100,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DraftSelectionKind = "chat" | "search";
|
||||
type SidebarItem = SidebarSelection & {
|
||||
type SidebarItem = HistorySelection & {
|
||||
title: string;
|
||||
searchSnippet: string | null;
|
||||
parentChatId: string | null;
|
||||
@@ -140,6 +144,7 @@ type RefreshCollectionsOptions = {
|
||||
function readSidebarSelectionFromUrl(): SidebarSelection | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("agent") === "root") return { kind: "agent", id: "root" };
|
||||
const chatId = params.get("chat")?.trim();
|
||||
if (chatId) {
|
||||
return { kind: "chat", id: chatId };
|
||||
@@ -156,8 +161,9 @@ function buildWorkspaceUrl(selection: SidebarSelection | null) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("chat");
|
||||
params.delete("search");
|
||||
params.delete("agent");
|
||||
if (selection) {
|
||||
params.set(selection.kind === "chat" ? "chat" : "search", selection.id);
|
||||
params.set(selection.kind, selection.id);
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${window.location.pathname}${query ? `?${query}` : ""}`;
|
||||
@@ -964,6 +970,7 @@ export default function App() {
|
||||
handleSignIn,
|
||||
logout,
|
||||
} = useSessionAuth();
|
||||
const agentRoom = useAgentRoom(isAuthenticated);
|
||||
|
||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
||||
@@ -1515,6 +1522,8 @@ export default function App() {
|
||||
selectedItem?.kind === "search" &&
|
||||
(!!selectedSearchRunState || !!activeRuns.searches[selectedItem.id]);
|
||||
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
|
||||
const isAgentMode = draftKind === null && selectedItem?.kind === "agent";
|
||||
const isAgentBusy = !!agentRoom.activity?.isThinking || (agentRoom.activity?.backgroundWorkers ?? 0) > 0;
|
||||
const isSearchRunning = !!selectedSearchIsActive;
|
||||
const isSendingActiveChat = draftKind !== "search" && !!selectedChatIsActive;
|
||||
const isActiveSelectionSending = isSearchMode ? isSearchRunning : isSendingActiveChat;
|
||||
@@ -1577,9 +1586,10 @@ export default function App() {
|
||||
setSelectedSearch(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedItem) {
|
||||
if (!selectedItem || selectedItem.kind === "agent") {
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
setIsLoadingSelection(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1739,6 +1749,7 @@ export default function App() {
|
||||
if (draftKind === "chat") return draftChatTitle.trim() || "New chat";
|
||||
if (draftKind === "search") return "New search";
|
||||
if (!selectedItem) return "Sybil";
|
||||
if (selectedItem.kind === "agent") return "Sybil Agent";
|
||||
if (selectedItem.kind === "chat") {
|
||||
if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages);
|
||||
if (selectedChatSummary) return getChatTitle(selectedChatSummary);
|
||||
@@ -1751,6 +1762,7 @@ export default function App() {
|
||||
|
||||
const pageTitle = useMemo(() => {
|
||||
if (draftKind || !selectedItem) return "Sybil";
|
||||
if (selectedItem.kind === "agent") return "Sybil Agent — Sybil";
|
||||
if (selectedItem.kind === "chat") {
|
||||
if (selectedChat?.id === selectedItem.id) return `${getChatTitle(selectedChat, selectedChat.messages)} — Sybil`;
|
||||
if (selectedChatSummary) return `${getChatTitle(selectedChatSummary)} — Sybil`;
|
||||
@@ -1925,7 +1937,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const selectAdjacentSidebarItem = (direction: -1 | 1) => {
|
||||
if (!orderedSidebarItems.length) return;
|
||||
const navigationItems: SidebarSelection[] = [{ kind: "agent", id: "root" }, ...orderedSidebarItems];
|
||||
|
||||
setError(null);
|
||||
setContextMenu(null);
|
||||
@@ -1934,15 +1946,14 @@ export default function App() {
|
||||
setIsMobileSidebarOpen(false);
|
||||
const current = selectedItemRef.current;
|
||||
const currentIndex = current
|
||||
? orderedSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
|
||||
? navigationItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
|
||||
: -1;
|
||||
const fallbackIndex = direction > 0 ? 0 : orderedSidebarItems.length - 1;
|
||||
const fallbackIndex = direction > 0 ? 0 : navigationItems.length - 1;
|
||||
const nextIndex =
|
||||
currentIndex < 0
|
||||
? fallbackIndex
|
||||
: Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction));
|
||||
const nextItem = orderedSidebarItems[nextIndex];
|
||||
const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id };
|
||||
: Math.min(navigationItems.length - 1, Math.max(0, currentIndex + direction));
|
||||
const target = navigationItems[nextIndex];
|
||||
rememberHistoryResult(target);
|
||||
selectedItemRef.current = target;
|
||||
setSelectedItem(target);
|
||||
@@ -3501,7 +3512,7 @@ export default function App() {
|
||||
</div>
|
||||
<SybilCharacter
|
||||
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
||||
isBusy={isActiveSelectionSending || isQuickQuestionSending}
|
||||
isBusy={isActiveSelectionSending || isQuickQuestionSending || isAgentBusy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3537,6 +3548,42 @@ export default function App() {
|
||||
Shift {primaryShortcutModifier} J
|
||||
</span>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="agent-sidebar-item"
|
||||
aria-current={isAgentMode ? "page" : undefined}
|
||||
aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount} unread messages` : ""}${isAgentBusy ? ", working" : ""}`}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
setError(null);
|
||||
setIsChatSettingsOpen(false);
|
||||
draftKindRef.current = null;
|
||||
setDraftKind(null);
|
||||
const target: SidebarSelection = { kind: "agent", id: "root" };
|
||||
selectedItemRef.current = target;
|
||||
setSelectedItem(target);
|
||||
setIsMobileSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isAgentMode
|
||||
? "border-violet-300/45 bg-[linear-gradient(135deg,hsl(258_86%_52%_/_0.58),hsl(277_78%_28%_/_0.55))] text-violet-50"
|
||||
: "border-transparent text-violet-100/78 hover:border-violet-300/18 hover:bg-violet-400/10"
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border", isAgentMode ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/20 bg-violet-400/5 text-violet-200")}>
|
||||
<Bot className="h-[18px] w-[18px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">Sybil Agent</span>
|
||||
</span>
|
||||
{isAgentBusy ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-cyan-200" aria-label="Agent working" /> : null}
|
||||
{agentRoom.unreadCount > 0 ? (
|
||||
<span data-testid="agent-unread-badge" className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-cyan-200 px-1.5 text-[11px] font-bold tabular-nums text-slate-950">
|
||||
{agentRoom.unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
|
||||
<input
|
||||
@@ -3683,6 +3730,9 @@ export default function App() {
|
||||
onTouchEnd={handleMobileWorkspaceTouchEnd}
|
||||
onTouchCancel={handleMobileWorkspaceTouchCancel}
|
||||
>
|
||||
{isAgentMode ? (
|
||||
<AgentConversation room={agentRoom} onOpenSidebar={() => setIsMobileSidebarOpen(true)} isVisible={!isMobileSidebarOpen && !isQuickQuestionOpen} />
|
||||
) : <>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
@@ -3820,6 +3870,7 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</>}
|
||||
</main>
|
||||
</div>
|
||||
{contextMenu ? (
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
.agent-room {
|
||||
height:100%;
|
||||
min-height:0;
|
||||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
background:radial-gradient(ellipse at 80% 0%,hsl(266 80% 30%/.12),transparent 42%),hsl(237 43% 6%/.72);
|
||||
color:hsl(258 36% 96%);
|
||||
}
|
||||
|
||||
.agent-room__body {
|
||||
position:relative;
|
||||
display:flex;
|
||||
flex:1;
|
||||
min-height:0;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.agent-room__scroll {
|
||||
width:100%;
|
||||
overflow:auto;
|
||||
overscroll-behavior:contain;
|
||||
scrollbar-gutter:stable;
|
||||
outline:none;
|
||||
overflow-anchor:none;
|
||||
}
|
||||
|
||||
.agent-room__scroll:focus-visible {
|
||||
outline:1px solid hsl(264 80% 74%/.48);
|
||||
outline-offset:-2px;
|
||||
}
|
||||
|
||||
.agent-room__timeline {
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content:flex-end;
|
||||
gap:15px;
|
||||
max-width:848px;
|
||||
min-height:100%;
|
||||
margin:0 auto;
|
||||
padding:24px 30px 22px;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
|
||||
.agent-room__entry {
|
||||
flex-shrink:0;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.agent-room__day {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:18px;
|
||||
margin:17px 0 24px;
|
||||
color:hsl(255 19% 64%);
|
||||
font-size:10px;
|
||||
letter-spacing:.05em;
|
||||
}
|
||||
|
||||
.agent-room__day:before,.agent-room__day:after {
|
||||
content:"";
|
||||
height:1px;
|
||||
width:48px;
|
||||
background:linear-gradient(90deg,transparent,hsl(260 48% 65%/.19));
|
||||
}
|
||||
|
||||
.agent-room__day:after {
|
||||
transform:rotate(180deg);
|
||||
}
|
||||
|
||||
.agent-room__message {
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
gap:9px;
|
||||
min-width:0;
|
||||
max-width:89%;
|
||||
}
|
||||
|
||||
.agent-room__message[data-animation="enter"] {
|
||||
animation:agent-room-message-enter 240ms cubic-bezier(.2,.7,.25,1) both;
|
||||
}
|
||||
|
||||
.agent-room__message.is-own {
|
||||
margin-left:auto;
|
||||
justify-content:flex-end;
|
||||
max-width:78%;
|
||||
}
|
||||
|
||||
.agent-room__message-avatar {
|
||||
display:grid;
|
||||
place-items:center;
|
||||
flex:none;
|
||||
align-self:flex-end;
|
||||
width:26px;
|
||||
height:26px;
|
||||
margin-bottom:23px;
|
||||
border:1px solid hsl(267 75% 73%/.2);
|
||||
border-radius:9px;
|
||||
background:hsl(261 52% 20%/.56);
|
||||
color:hsl(269 72% 80%);
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.agent-room__message-avatar img {
|
||||
display:block;
|
||||
width:100%;
|
||||
height:100%;
|
||||
object-fit:cover;
|
||||
}
|
||||
|
||||
.agent-room__message-content {
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.agent-room__plain-message {
|
||||
margin:0;
|
||||
white-space:pre-wrap;
|
||||
}
|
||||
|
||||
.agent-room__message-meta {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
margin:5px 3px 0;
|
||||
color:hsl(253 18% 55%);
|
||||
font-size:9px;
|
||||
line-height:1.4;
|
||||
}
|
||||
|
||||
.agent-room__message.is-own .agent-room__message-meta {
|
||||
justify-content:flex-end;
|
||||
}
|
||||
|
||||
.agent-room__message-meta>time:before {
|
||||
content:"·";
|
||||
margin-right:7px;
|
||||
opacity:.6;
|
||||
}
|
||||
|
||||
.agent-room__delivery {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:3px;
|
||||
color:hsl(272 52% 77%);
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.agent-room__delivery.is-error {
|
||||
color:hsl(349 70% 78%);
|
||||
}
|
||||
|
||||
.agent-room__delivery button {
|
||||
border:0;
|
||||
padding:0 0 0 3px;
|
||||
background:transparent;
|
||||
color:inherit;
|
||||
font:inherit;
|
||||
text-decoration:underline;
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
.agent-room .md-content p {
|
||||
margin-top:.55em;
|
||||
margin-bottom:.55em;
|
||||
}
|
||||
|
||||
.agent-room .md-content>:first-child {
|
||||
margin-top:0;
|
||||
}
|
||||
|
||||
.agent-room .md-content>:last-child {
|
||||
margin-bottom:0;
|
||||
}
|
||||
|
||||
.agent-room .md-content pre {
|
||||
max-width:100%;
|
||||
font-size:12px;
|
||||
}
|
||||
|
||||
.agent-room .md-content h1 {
|
||||
font-size:1.25em;
|
||||
}
|
||||
|
||||
.agent-room .md-content h2 {
|
||||
font-size:1.15em;
|
||||
}
|
||||
|
||||
.agent-room .md-content h3 {
|
||||
font-size:1.05em;
|
||||
}
|
||||
|
||||
.agent-room .md-content a {
|
||||
color:hsl(189 79% 75%);
|
||||
}
|
||||
|
||||
.agent-room__tool {
|
||||
margin:0 0 0 35px;
|
||||
max-width:calc(89% - 35px);
|
||||
border:1px solid hsl(256 35% 47%/.23);
|
||||
border-radius:11px;
|
||||
background:hsl(246 32% 12%/.56);
|
||||
color:hsl(255 26% 75%);
|
||||
font-size:11px;
|
||||
}
|
||||
|
||||
.agent-room__tool summary {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:9px;
|
||||
min-height:37px;
|
||||
padding:8px 11px;
|
||||
cursor:pointer;
|
||||
list-style:none;
|
||||
}
|
||||
|
||||
.agent-room__tool summary::-webkit-details-marker {
|
||||
display:none;
|
||||
}
|
||||
|
||||
.agent-room__tool-icon {
|
||||
display:grid;
|
||||
place-items:center;
|
||||
color:hsl(267 69% 76%);
|
||||
}
|
||||
|
||||
.agent-room__tool-title {
|
||||
min-width:0;
|
||||
flex:1;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
text-transform:capitalize;
|
||||
}
|
||||
|
||||
.agent-room__tool-state {
|
||||
color:hsl(254 18% 51%);
|
||||
font-size:9px;
|
||||
}
|
||||
|
||||
.agent-room__tool.is-running .agent-room__tool-state {
|
||||
color:hsl(184 54% 72%);
|
||||
}
|
||||
|
||||
.agent-room__tool.is-failed .agent-room__tool-state {
|
||||
color:hsl(351 71% 76%);
|
||||
}
|
||||
|
||||
.agent-room__tool-chevron {
|
||||
color:hsl(255 20% 52%);
|
||||
transition:transform .15s;
|
||||
}
|
||||
|
||||
.agent-room__tool[open] .agent-room__tool-chevron {
|
||||
transform:rotate(180deg);
|
||||
}
|
||||
|
||||
.agent-room__tool-body {
|
||||
padding:0 11px 11px;
|
||||
}
|
||||
|
||||
.agent-room__tool-body pre {
|
||||
max-height:210px;
|
||||
overflow:auto;
|
||||
white-space:pre-wrap;
|
||||
overflow-wrap:anywhere;
|
||||
font:10px/1.6 ui-monospace,monospace;
|
||||
margin:8px 0 0;
|
||||
padding:9px;
|
||||
border-radius:6px;
|
||||
background:hsl(239 42% 6%/.7);
|
||||
}
|
||||
|
||||
.agent-room__typing,.agent-room__waiting {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:9px;
|
||||
flex:none;
|
||||
min-height:20px;
|
||||
margin-left:36px;
|
||||
color:hsl(254 22% 63%);
|
||||
font-size:11px;
|
||||
}
|
||||
|
||||
.agent-room__typing-dots {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:3px;
|
||||
padding:6px 8px;
|
||||
border-radius:9px;
|
||||
background:hsl(258 43% 22%/.42);
|
||||
}
|
||||
|
||||
.agent-room__typing-dots i {
|
||||
width:3px;
|
||||
height:3px;
|
||||
border-radius:50%;
|
||||
background:hsl(268 72% 78%);
|
||||
animation:agent-room-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-room__typing-dots i:nth-child(2) {
|
||||
animation-delay:.16s;
|
||||
}
|
||||
|
||||
.agent-room__typing-dots i:nth-child(3) {
|
||||
animation-delay:.32s;
|
||||
}
|
||||
|
||||
.agent-room__worker-count {
|
||||
padding:2px 5px;
|
||||
border-radius:5px;
|
||||
background:hsl(265 58% 27%/.3);
|
||||
color:hsl(268 58% 76%);
|
||||
font-size:9px;
|
||||
}
|
||||
|
||||
.agent-room__jump {
|
||||
position:absolute;
|
||||
z-index:2;
|
||||
left:50%;
|
||||
bottom:14px;
|
||||
transform:translateX(-50%);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
white-space:nowrap;
|
||||
padding:9px 14px;
|
||||
border:1px solid hsl(266 76% 72%/.43);
|
||||
border-radius:99px;
|
||||
background:hsl(256 46% 20%/.96);
|
||||
color:hsl(263 60% 93%);
|
||||
box-shadow:0 5px 24px hsl(240 70% 2%/.5);
|
||||
font-size:11px;
|
||||
cursor:pointer;
|
||||
backdrop-filter:blur(14px);
|
||||
}
|
||||
|
||||
.agent-room__jump:hover {
|
||||
background:hsl(261 48% 27%);
|
||||
}
|
||||
|
||||
.agent-room__composer-area {
|
||||
flex:none;
|
||||
border-top:1px solid hsl(253 40% 40%/.14);
|
||||
padding:16px 30px max(13px,var(--safe-area-bottom));
|
||||
background:linear-gradient(180deg,hsl(240 41% 7%/.72),hsl(240 43% 7%/.97));
|
||||
}
|
||||
|
||||
.agent-room__composer-inner {
|
||||
max-width:788px;
|
||||
margin:0 auto;
|
||||
}
|
||||
|
||||
.agent-room__composer {
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
gap:10px;
|
||||
padding:7px 8px 7px 5px;
|
||||
border:1px solid hsl(263 51% 58%/.3);
|
||||
border-radius:17px;
|
||||
background:hsl(247 35% 13%/.9);
|
||||
box-shadow:inset 0 1px 0 hsl(266 80% 92%/.04),0 6px 24px hsl(239 80% 2%/.12);
|
||||
transition:border-color .15s,box-shadow .15s;
|
||||
}
|
||||
|
||||
.agent-room__composer:focus-within {
|
||||
border-color:hsl(265 74% 69%/.65);
|
||||
box-shadow:0 0 0 3px hsl(266 69% 55%/.08);
|
||||
}
|
||||
|
||||
.agent-room__composer textarea {
|
||||
display:block;
|
||||
flex:1;
|
||||
min-width:0;
|
||||
min-height:46px;
|
||||
max-height:156px;
|
||||
margin:0;
|
||||
resize:none;
|
||||
overflow:auto;
|
||||
border:0;
|
||||
outline:0;
|
||||
background:transparent;
|
||||
padding:13px 11px;
|
||||
font-size:14px;
|
||||
line-height:20px;
|
||||
color:hsl(259 44% 96%);
|
||||
box-shadow:none;
|
||||
}
|
||||
|
||||
.agent-room__composer textarea::placeholder {
|
||||
color:hsl(255 23% 53%);
|
||||
}
|
||||
|
||||
.agent-room__send {
|
||||
display:grid;
|
||||
place-items:center;
|
||||
flex:none;
|
||||
width:38px;
|
||||
height:38px;
|
||||
margin-bottom:4px;
|
||||
border:1px solid hsl(265 86% 79%/.32);
|
||||
border-radius:11px;
|
||||
background:linear-gradient(140deg,hsl(264 80% 59%),hsl(270 75% 49%));
|
||||
color:hsl(265 65% 98%);
|
||||
box-shadow:0 3px 12px hsl(264 75% 45%/.22);
|
||||
cursor:pointer;
|
||||
transition:filter .15s,opacity .15s;
|
||||
}
|
||||
|
||||
.agent-room__send:hover:not(:disabled) {
|
||||
filter:brightness(1.17);
|
||||
}
|
||||
|
||||
.agent-room__send:disabled {
|
||||
opacity:.34;
|
||||
box-shadow:none;
|
||||
cursor:default;
|
||||
}
|
||||
|
||||
.agent-room__error {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:9px;
|
||||
margin-bottom:11px;
|
||||
padding:10px 12px;
|
||||
border:1px solid hsl(350 62% 62%/.2);
|
||||
border-radius:11px;
|
||||
background:hsl(340 45% 22%/.22);
|
||||
color:hsl(349 70% 85%);
|
||||
font-size:11px;
|
||||
}
|
||||
|
||||
.agent-room__error>span {
|
||||
min-width:0;
|
||||
flex:1;
|
||||
}
|
||||
|
||||
.agent-room__error>svg {
|
||||
flex:none;
|
||||
}
|
||||
|
||||
.agent-room__error button {
|
||||
flex:none;
|
||||
border:1px solid hsl(350 57% 66%/.28);
|
||||
border-radius:6px;
|
||||
padding:4px 8px;
|
||||
color:hsl(349 66% 90%);
|
||||
font-size:10px;
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
.agent-room__empty {
|
||||
flex:none;
|
||||
margin:auto;
|
||||
max-width:360px;
|
||||
padding:48px 15px;
|
||||
text-align:center;
|
||||
color:hsl(254 25% 65%);
|
||||
}
|
||||
|
||||
.agent-room button:focus-visible,.agent-room summary:focus-visible {
|
||||
outline:2px solid hsl(188 82% 71%);
|
||||
outline-offset:3px;
|
||||
}
|
||||
|
||||
@keyframes agent-room-dot {
|
||||
0%,70%,100% {
|
||||
opacity:.35;
|
||||
transform:translateY(0);
|
||||
}
|
||||
35% {
|
||||
opacity:1;
|
||||
transform:translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-room-message-enter {
|
||||
from {
|
||||
opacity:0;
|
||||
transform:translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:767px) {
|
||||
.agent-room__timeline {
|
||||
padding:17px 14px 20px;
|
||||
gap:14px;
|
||||
}
|
||||
.agent-room__message {
|
||||
max-width:96%;
|
||||
gap:7px;
|
||||
}
|
||||
.agent-room__message.is-own {
|
||||
max-width:87%;
|
||||
}
|
||||
.agent-room__message-avatar {
|
||||
width:23px;
|
||||
height:23px;
|
||||
border-radius:8px;
|
||||
}
|
||||
.agent-room__tool {
|
||||
margin-left:30px;
|
||||
max-width:calc(96% - 30px);
|
||||
}
|
||||
.agent-room__typing,.agent-room__waiting {
|
||||
margin-left:30px;
|
||||
font-size:10px;
|
||||
}
|
||||
.agent-room__composer-area {
|
||||
padding:12px 13px max(11px,var(--safe-area-bottom));
|
||||
}
|
||||
.agent-room__composer textarea {
|
||||
font-size:16px;
|
||||
line-height:22px;
|
||||
}
|
||||
.agent-room__empty {
|
||||
max-width:300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
.agent-room__message[data-animation="enter"] {
|
||||
animation:none;
|
||||
}
|
||||
.agent-room__typing-dots i {
|
||||
animation:none;
|
||||
opacity:.7;
|
||||
}
|
||||
.agent-room * {
|
||||
scroll-behavior:auto!important;
|
||||
transition:none!important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ArrowDown, ChevronDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff, Wrench } from "lucide-preact";
|
||||
import type { Message } from "@/lib/api";
|
||||
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "./agent-conversation.css";
|
||||
|
||||
type Props = {
|
||||
room: AgentRoomHookResult;
|
||||
onOpenSidebar: () => void;
|
||||
/** False while a mobile navigation overlay covers this pane. */
|
||||
isVisible?: boolean;
|
||||
};
|
||||
|
||||
type ReadingAnchor = { id: string; offset: number };
|
||||
const DRAFT_KEY = "sybil.pi-root.draft";
|
||||
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
|
||||
const dayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
|
||||
function savedDraft() {
|
||||
try { return sessionStorage.getItem(DRAFT_KEY) || ""; } catch { return ""; }
|
||||
}
|
||||
function saveDraft(value: string) {
|
||||
try { if (value) sessionStorage.setItem(DRAFT_KEY, value); else sessionStorage.removeItem(DRAFT_KEY); } catch { /* A blocked browser store does not prevent chatting. */ }
|
||||
}
|
||||
function metadata(message: Message): Record<string, unknown> {
|
||||
return message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
|
||||
? message.metadata as Record<string, unknown> : {};
|
||||
}
|
||||
function dateOf(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) || date.getFullYear() < 2000 ? null : date;
|
||||
}
|
||||
function dayKey(date: Date | null) {
|
||||
return date ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : "older";
|
||||
}
|
||||
function dayLabel(date: Date | null) {
|
||||
if (!date) return "Earlier";
|
||||
const today = new Date();
|
||||
if (dayKey(date) === dayKey(today)) return "Today";
|
||||
today.setDate(today.getDate() - 1);
|
||||
return dayKey(date) === dayKey(today) ? "Yesterday" : dayFormat.format(date);
|
||||
}
|
||||
function toolName(value: unknown) {
|
||||
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
||||
}
|
||||
|
||||
function AgentToolCard({ message }: { message: Message }) {
|
||||
const info = metadata(message);
|
||||
const active = info.status === "initiated";
|
||||
const failed = info.status === "failed";
|
||||
const title = toolName(info.toolName || message.name);
|
||||
const preview = typeof info.resultPreview === "string" ? info.resultPreview : typeof info.error === "string" ? info.error : "";
|
||||
return (
|
||||
<details className={`agent-room__tool${active ? " is-running" : ""}${failed ? " is-failed" : ""}`} data-agent-message-id={message.id}>
|
||||
<summary>
|
||||
<span className="agent-room__tool-icon"><Wrench size={14} aria-hidden="true" /></span>
|
||||
<span className="agent-room__tool-title">{title}</span>
|
||||
<span className="agent-room__tool-state">{active ? "Running" : failed ? "Failed" : "Done"}</span>
|
||||
<ChevronDown className="agent-room__tool-chevron" size={14} aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="agent-room__tool-body">
|
||||
{info.args && typeof info.args === "object" ? <pre aria-label="Tool input">{JSON.stringify(info.args, null, 2)}</pre> : null}
|
||||
{preview ? <pre aria-label={failed ? "Tool error" : "Tool result"}>{preview}</pre> : null}
|
||||
{!info.args && !preview ? <p>{active ? "This tool is still running." : "No additional details."}</p> : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) {
|
||||
const own = message.role === "user";
|
||||
const info = metadata(message);
|
||||
const status = typeof info.status === "string" ? info.status : "complete";
|
||||
const failed = status === "failed" || status === "unconfirmed";
|
||||
const date = dateOf(message.createdAt);
|
||||
return (
|
||||
<article className={`agent-room__message ${own ? "is-own" : "is-agent"}`} data-agent-message-id={message.id} data-message-status={status} data-animation={animate ? "enter" : undefined} aria-label={own ? "Your message" : "Sybil's message"}>
|
||||
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
|
||||
<div className="agent-room__message-content">
|
||||
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
||||
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab />}
|
||||
</div>
|
||||
<div className="agent-room__message-meta">
|
||||
<span>{own ? "You" : "Sybil"}</span>
|
||||
{date ? <time dateTime={message.createdAt} title={date.toLocaleString()}>{timeFormat.format(date)}</time> : null}
|
||||
{own && status === "sending" ? <span className="agent-room__delivery" role="status">Sending…</span> : null}
|
||||
{own && status === "queued" ? <span className="agent-room__delivery" role="status"><Clock3 size={11} aria-hidden="true" /> Queued</span> : null}
|
||||
{own && failed ? <span className="agent-room__delivery is-error" title={typeof info.error === "string" ? info.error : undefined} role="status">
|
||||
{status === "unconfirmed" ? "Unconfirmed" : "Not sent"}
|
||||
<button type="button" onClick={() => onRetry(message.id)} aria-label="Retry message">Retry</button>
|
||||
</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
|
||||
const [draft, setDraft] = useState(savedDraft);
|
||||
const draftRef = useRef(draft);
|
||||
const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id)));
|
||||
const enteringMessageIdsRef = useRef(new Set<string>());
|
||||
const animatedEntryIdsRef = useRef(new Set<string>());
|
||||
const entryAnimationsRef = useRef(new Set<Animation>());
|
||||
const typingHeightRef = useRef(0);
|
||||
const historyReadyRef = useRef(!room.isLoading);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pinnedRef = useRef(true);
|
||||
const restoredScrollTopRef = useRef<number | null>(null);
|
||||
const anchorRef = useRef<ReadingAnchor | null>(null);
|
||||
const markReadRef = useRef(room.markRead);
|
||||
const visibleRef = useRef(isVisible);
|
||||
const loadingRef = useRef(room.isLoading);
|
||||
const readFrameRef = useRef<number | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
markReadRef.current = room.markRead;
|
||||
visibleRef.current = isVisible;
|
||||
loadingRef.current = room.isLoading;
|
||||
|
||||
const distanceFromBottom = () => {
|
||||
const element = scrollerRef.current;
|
||||
return element ? element.scrollHeight - element.scrollTop - element.clientHeight : Infinity;
|
||||
};
|
||||
const scheduleRead = () => {
|
||||
if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current);
|
||||
readFrameRef.current = requestAnimationFrame(() => {
|
||||
readFrameRef.current = null;
|
||||
if (mountedRef.current && visibleRef.current && !loadingRef.current && entryAnimationsRef.current.size === 0 && document.visibilityState === "visible" && document.hasFocus() && distanceFromBottom() <= 24) {
|
||||
markReadRef.current();
|
||||
}
|
||||
});
|
||||
};
|
||||
const rememberAnchor = () => {
|
||||
const element = scrollerRef.current;
|
||||
if (!element || pinnedRef.current) { anchorRef.current = null; return; }
|
||||
const top = element.getBoundingClientRect().top;
|
||||
// Anchor to layout, independently of the bubble's entrance transform.
|
||||
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.getBoundingClientRect().bottom > top + 8);
|
||||
anchorRef.current = node ? { id: node.dataset.agentEntryId!, offset: node.getBoundingClientRect().top - top } : null;
|
||||
};
|
||||
const restoreViewport = () => {
|
||||
const element = scrollerRef.current;
|
||||
if (!element) return;
|
||||
if (pinnedRef.current) element.scrollTop = element.scrollHeight;
|
||||
else if (anchorRef.current) {
|
||||
const anchor = anchorRef.current;
|
||||
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.dataset.agentEntryId === anchor.id);
|
||||
if (node) element.scrollTop += node.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
|
||||
}
|
||||
restoredScrollTopRef.current = element.scrollTop;
|
||||
rememberAnchor();
|
||||
scheduleRead();
|
||||
};
|
||||
const jumpToLatest = () => {
|
||||
pinnedRef.current = true;
|
||||
anchorRef.current = null;
|
||||
setAtBottom(true);
|
||||
if (scrollerRef.current) {
|
||||
scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
|
||||
restoredScrollTopRef.current = scrollerRef.current.scrollTop;
|
||||
}
|
||||
scheduleRead();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) return;
|
||||
const gap = parseFloat(getComputedStyle(timeline).rowGap) || 0;
|
||||
const typing = timeline.querySelector<HTMLElement>(".agent-room__typing");
|
||||
// Transfer the departing indicator's space to the reply before it grows.
|
||||
// Removing that space first would briefly move the transcript downward.
|
||||
let replacedTypingHeight = typing ? 0 : typingHeightRef.current;
|
||||
typingHeightRef.current = typing ? typing.offsetHeight + (typing.previousElementSibling ? gap : 0) : 0;
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
for (const entry of timeline.querySelectorAll<HTMLElement>("[data-agent-entry-id]")) {
|
||||
const id = entry.dataset.agentEntryId!;
|
||||
if (animatedEntryIdsRef.current.has(id) || !enteringMessageIdsRef.current.has(id)) continue;
|
||||
animatedEntryIdsRef.current.add(id);
|
||||
if (reduceMotion) continue;
|
||||
const height = entry.getBoundingClientRect().height;
|
||||
const spacing = entry.previousElementSibling ? gap : 0;
|
||||
const isReply = Boolean(entry.querySelector(".agent-room__message.is-agent"));
|
||||
const initialHeight = isReply ? Math.min(replacedTypingHeight, height + spacing) : 0;
|
||||
if (isReply) replacedTypingHeight = 0;
|
||||
// Include the inter-message gap in the reveal. Clipping keeps the bubble's
|
||||
// positive-Y transform from prematurely extending the scrollable area.
|
||||
entry.style.overflow = "clip";
|
||||
const animation = entry.animate([
|
||||
{ height: `${initialHeight}px`, marginTop: `${-spacing}px` },
|
||||
{ height: `${height}px`, marginTop: "0px" },
|
||||
], { duration: 240, easing: "cubic-bezier(.2,.7,.25,1)" });
|
||||
entryAnimationsRef.current.add(animation);
|
||||
animation.onfinish = () => {
|
||||
entry.style.overflow = "";
|
||||
entryAnimationsRef.current.delete(animation);
|
||||
restoreViewport();
|
||||
};
|
||||
}
|
||||
}, [room.messages, room.isTyping]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
restoreViewport();
|
||||
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const observer = new ResizeObserver(restoreViewport);
|
||||
if (scrollerRef.current) observer.observe(scrollerRef.current);
|
||||
if (timelineRef.current) observer.observe(timelineRef.current);
|
||||
const onVisibility = () => scheduleRead();
|
||||
window.addEventListener("focus", onVisibility);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
observer.disconnect();
|
||||
for (const animation of entryAnimationsRef.current) animation.cancel();
|
||||
entryAnimationsRef.current.clear();
|
||||
window.removeEventListener("focus", onVisibility);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${Math.min(156, Math.max(46, textarea.scrollHeight))}px`;
|
||||
}, [draft]);
|
||||
|
||||
const updateDraft = (value: string) => {
|
||||
draftRef.current = value;
|
||||
saveDraft(value);
|
||||
setDraft(value);
|
||||
};
|
||||
|
||||
const retryMessage = (id: string) => {
|
||||
// Admission errors remain attached to their own bubble.
|
||||
void room.retryMessage(id).catch(() => {});
|
||||
};
|
||||
const submit = () => {
|
||||
const content = draftRef.current.trim();
|
||||
if (!content) return;
|
||||
const delivery = room.sendMessage(content);
|
||||
updateDraft("");
|
||||
jumpToLatest();
|
||||
void delivery.catch(() => {});
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const messages = useMemo(() => {
|
||||
let previousDay = "";
|
||||
for (const message of room.messages) {
|
||||
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading && message.role !== "tool") {
|
||||
enteringMessageIdsRef.current.add(message.id);
|
||||
}
|
||||
seenMessageIdsRef.current.add(message.id);
|
||||
}
|
||||
if (!room.isLoading) historyReadyRef.current = true;
|
||||
return room.messages.filter(message => message.role !== "system").map(message => {
|
||||
const date = dateOf(message.createdAt), day = dayKey(date), separator = previousDay !== day;
|
||||
previousDay = day;
|
||||
return (
|
||||
<div className="agent-room__entry" key={message.id} data-agent-entry-id={message.id}>
|
||||
{separator ? <div className="agent-room__day"><span>{dayLabel(date)}</span></div> : null}
|
||||
{message.role === "tool" ? <AgentToolCard message={message} /> : <AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, [room.messages, room.isLoading, room.retryMessage]);
|
||||
|
||||
const activity = room.activity;
|
||||
const running = room.isTyping;
|
||||
const waiting = activity?.state === "waiting";
|
||||
const connected = room.connection === "connected";
|
||||
const connectionLabel = connected ? "Connected" : room.connection === "offline" ? "Offline" : room.connection === "reconnecting" ? "Reconnecting" : "Connecting";
|
||||
const activeTool = activity?.tools?.find(tool => tool.status !== "done");
|
||||
const workingLabel = activeTool ? `Using ${toolName(activeTool.name)}`
|
||||
: activity?.isThinking ? "Sybil is thinking" : (activity?.backgroundWorkers || 0) > 0 ? "Working in the background" : "";
|
||||
|
||||
return (
|
||||
<section className="agent-room" aria-label="Sybil Agent conversation">
|
||||
<header className="agent-room__header flex shrink-0 items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button type="button" size="icon" variant="ghost" className="workspace-sidebar-trigger -ml-1 h-8 w-8 md:hidden" onClick={onOpenSidebar} aria-label="Open sidebar">
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">Sybil Agent</h1>
|
||||
</div>
|
||||
<div className="flex h-10 shrink-0 items-center justify-end gap-2">
|
||||
<span className="sr-only" role="status">{connectionLabel}</span>
|
||||
{!connected ? <Button type="button" size="icon" variant="ghost" onClick={room.retry} aria-label="Reconnect to Sybil Agent" title={connectionLabel}><RefreshCw className="h-4 w-4" /></Button> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="agent-room__body">
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="agent-room__scroll"
|
||||
tabIndex={0}
|
||||
aria-label="Conversation messages"
|
||||
onScroll={() => {
|
||||
// A scroll event from our last resize correction can arrive after
|
||||
// the next animation frame has already grown a tall reply. That
|
||||
// temporary bottom distance does not mean the reader scrolled up.
|
||||
if (restoredScrollTopRef.current !== null && scrollerRef.current
|
||||
&& Math.abs(scrollerRef.current.scrollTop - restoredScrollTopRef.current) < 1) return;
|
||||
const bottom = distanceFromBottom() <= 48;
|
||||
pinnedRef.current = bottom;
|
||||
setAtBottom(current => current === bottom ? current : bottom);
|
||||
rememberAnchor();
|
||||
if (bottom) scheduleRead();
|
||||
}}
|
||||
>
|
||||
<div ref={timelineRef} className="agent-room__timeline">
|
||||
{room.isLoading && !messages.length ? <div className="agent-room__empty" role="status"><LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /><span className="sr-only">Loading conversation</span></div> : null}
|
||||
{messages}
|
||||
{running ? <div className="agent-room__typing" role="status" aria-live="polite"><span className="agent-room__typing-dots" aria-hidden="true"><i /><i /><i /></span><span>{workingLabel}</span>{(activity?.backgroundWorkers || 0) > 1 ? <span className="agent-room__worker-count">{activity!.backgroundWorkers} workers</span> : null}</div> : null}
|
||||
{!running && waiting && activity?.wait ? <div className="agent-room__waiting" role="status"><Clock3 size={13} /><span>Checking back at {timeFormat.format(new Date(activity.wait.due))}</span></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{!atBottom ? <button className="agent-room__jump" onClick={jumpToLatest}><ArrowDown size={15} /><span>{room.unreadCount ? `${room.unreadCount} new message${room.unreadCount === 1 ? "" : "s"}` : "Jump to latest"}</span></button> : null}
|
||||
</div>
|
||||
|
||||
<div className="agent-room__composer-area">
|
||||
<div className="agent-room__composer-inner">
|
||||
{room.error ? <div className="agent-room__error" role="alert"><WifiOff size={15} /><span>{room.error}</span><button onClick={room.retry}>Retry</button></div> : null}
|
||||
<form className="agent-room__composer" onSubmit={event => { event.preventDefault(); submit(); }}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
rows={1}
|
||||
onInput={event => updateDraft(event.currentTarget.value)}
|
||||
onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); submit(); } }}
|
||||
placeholder="Message Sybil Agent…"
|
||||
aria-label="Message Sybil Agent"
|
||||
enterkeyhint="send"
|
||||
/>
|
||||
<button type="submit" className="agent-room__send" disabled={!draft.trim()} aria-label="Send message"><SendHorizontal size={19} /></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ function getTextPreview(value: string) {
|
||||
|
||||
function getSurfaceClasses(tone: Props["tone"]) {
|
||||
if (tone === "user") {
|
||||
return "border-white/12 bg-black/16 text-fuchsia-50";
|
||||
return "border-white/12 bg-black/16 text-inherit";
|
||||
}
|
||||
if (tone === "assistant") {
|
||||
return "border-violet-300/16 bg-violet-400/8 text-violet-50";
|
||||
|
||||
@@ -601,10 +601,10 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] space-y-3",
|
||||
"min-w-0 space-y-3",
|
||||
isUser
|
||||
? "chat-user-message border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
||||
: "text-base leading-7 text-violet-50"
|
||||
? "message-bubble message-bubble--user max-w-[87%] md:max-w-[78%]"
|
||||
: "max-w-[85%] text-base leading-7 text-violet-50"
|
||||
)}
|
||||
onContextMenu={
|
||||
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
|
||||
@@ -625,7 +625,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
<MarkdownContent
|
||||
markdown={message.content}
|
||||
openLinksInNewTab
|
||||
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
|
||||
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
|
||||
import {
|
||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentReadState,
|
||||
createAgentReadWriter, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||
type AgentActivity, type AgentRoomState,
|
||||
} from "@/lib/agent-room";
|
||||
import { AGENT_OUTBOX_STORAGE_KEY, createAgentOptimisticOutbox } from "@/lib/agent-optimistic-outbox";
|
||||
import { createAgentRoomPresenter } from "@/lib/agent-room-presentation";
|
||||
|
||||
export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" | "offline";
|
||||
export type AgentRoomHookResult = {
|
||||
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
|
||||
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
|
||||
sendMessage(content: string): Promise<void>; retryMessage(messageId: string): Promise<void>; markRead(): void; retry(): void;
|
||||
};
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
|
||||
/** Mount once in App; selection controls reading, never the lifetime of this subscription. */
|
||||
export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
const [view, setView] = useState(() => ({ room: createAgentRoomState(), isTyping: false }));
|
||||
const room = view.room;
|
||||
const current = useRef(room);
|
||||
const [connection, setConnection] = useState<AgentRoomConnection>("offline");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setLoading] = useState(enabled);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const client = useRef<AgentRoomClient | null>(null);
|
||||
const mark = useRef<((cursor: string) => void) | null>(null);
|
||||
const [outboxRevision, setOutboxRevision] = useState(0);
|
||||
const update = useCallback((change: (state: AgentRoomState) => AgentRoomState) => {
|
||||
const next = change(current.current); current.current = next; setView(previous => ({ ...previous, room: next }));
|
||||
}, []);
|
||||
const outbox = useRef<ReturnType<typeof createAgentOptimisticOutbox> | null>(null);
|
||||
if (!outbox.current) {
|
||||
const stored = (key: string) => { try { return JSON.parse(sessionStorage.getItem(key) || "null"); } catch { return null; } };
|
||||
outbox.current = createAgentOptimisticOutbox({
|
||||
submit: (content, requestId) => {
|
||||
if (!client.current) throw new Error("Agent connection is unavailable.");
|
||||
return client.current.submit(content, requestId);
|
||||
},
|
||||
changed: () => setOutboxRevision(value => value + 1),
|
||||
storage: {
|
||||
read: () => stored(AGENT_OUTBOX_STORAGE_KEY),
|
||||
legacyRows: () => stored("sybil.pi-root.outbox"),
|
||||
legacyIds: () => stored(AGENT_ADMISSION_STORAGE_KEY),
|
||||
write: rows => {
|
||||
try {
|
||||
// Keep [] as a migration marker; legacy keys are retired only once
|
||||
// their replacement has been saved successfully.
|
||||
sessionStorage.setItem(AGENT_OUTBOX_STORAGE_KEY, JSON.stringify(rows));
|
||||
sessionStorage.removeItem("sybil.pi-root.outbox");
|
||||
sessionStorage.removeItem(AGENT_ADMISSION_STORAGE_KEY);
|
||||
} catch { throw new Error("Could not save this message for retry. Allow session storage or free browser storage, then retry."); }
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
client.current = null; mark.current = null;
|
||||
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let socket: WebSocket | undefined;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let socketTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let reconnectAttempt = 0;
|
||||
let started = false;
|
||||
let maintaining = false;
|
||||
let lastPacket = Date.now();
|
||||
let socketAuthenticated = false;
|
||||
let authFailed = false;
|
||||
const controller = new AbortController();
|
||||
const api = new AgentRoomClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
|
||||
client.current = api;
|
||||
const presenter = createAgentRoomPresenter({ initial: current.current, reduceEvent: applyAgentEvent,
|
||||
publish: (next, isTyping) => {
|
||||
if (!active) return;
|
||||
outbox.current?.reconcile(next);
|
||||
current.current = next;
|
||||
setView({ room: next, isTyping });
|
||||
},
|
||||
});
|
||||
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
|
||||
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
|
||||
// Read acknowledgments may be ahead of the batched UI for ~100ms. The
|
||||
// writer must observe that acknowledged cursor immediately to avoid a
|
||||
// repeated PUT loop while the presenter waits to publish its batch.
|
||||
let acknowledgedReadCursor = current.current.readCursor;
|
||||
const readWriter = createAgentReadWriter({
|
||||
current: () => acknowledgedReadCursor,
|
||||
save: cursor => api.markRead(cursor, controller.signal),
|
||||
apply: read => {
|
||||
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
|
||||
if (active) presenter.update(state => applyAgentReadState(state, read));
|
||||
},
|
||||
});
|
||||
mark.current = cursor => { void readWriter.request(cursor).catch(reportError); };
|
||||
|
||||
const refreshRead = async () => {
|
||||
const read = await api.readState(controller.signal);
|
||||
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
|
||||
if (active) presenter.update(state => applyAgentReadState(state, read));
|
||||
};
|
||||
const catchUp = async (replay = false) => syncAgentHistory(api, current.current.cursor, page => {
|
||||
if (active) presenter.push({ type: "messages", ...page }, { replay });
|
||||
}, controller.signal);
|
||||
const refreshActivity = async () => {
|
||||
const descriptor = await api.descriptor(controller.signal);
|
||||
if (active) presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity });
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (!active || authFailed) return;
|
||||
clearTimeout(reconnectTimer); clearTimeout(socketTimeout);
|
||||
setConnection(reconnectAttempt ? "reconnecting" : "connecting");
|
||||
socketAuthenticated = false;
|
||||
const next = new WebSocket(api.websocketUrl(window.location.href));
|
||||
socket = next;
|
||||
socketTimeout = setTimeout(() => { if (!socketAuthenticated) next.close(); }, 12_000);
|
||||
next.onopen = () => {
|
||||
if (!active || socket !== next) { next.close(); return; }
|
||||
lastPacket = Date.now();
|
||||
next.send(agentAuthFrame(getAuthToken(), current.current.cursor));
|
||||
};
|
||||
next.onmessage = ({ data }) => {
|
||||
if (!active || socket !== next) return;
|
||||
lastPacket = Date.now();
|
||||
try {
|
||||
const event = parseAgentRoomEvent(String(data));
|
||||
if (!event) return;
|
||||
if (event.type === "error") { reportError(new Error(event.error || event.message || "Agent connection error.")); return; }
|
||||
if (event.type === "ready") {
|
||||
clearTimeout(socketTimeout); socketAuthenticated = true; reconnectAttempt = 0;
|
||||
setConnection("connected"); setError(null); return;
|
||||
}
|
||||
presenter.push(event);
|
||||
} catch (failure) {
|
||||
reportError(failure);
|
||||
// A malformed message must not advance the replay cursor. Reconnect
|
||||
// from the last applied page, rather than silently skipping that input.
|
||||
next.close();
|
||||
}
|
||||
};
|
||||
next.onerror = () => { if (active) setConnection("reconnecting"); };
|
||||
next.onclose = event => {
|
||||
if (!active || socket !== next) return;
|
||||
clearTimeout(socketTimeout); socketAuthenticated = false;
|
||||
if (event.code === 4401) {
|
||||
authFailed = true; setConnection("offline"); setError("Agent authentication failed. Sign in again or retry."); return;
|
||||
}
|
||||
setConnection("reconnecting");
|
||||
const wait = Math.min(30_000, 1000 * 2 ** Math.min(reconnectAttempt++, 5));
|
||||
reconnectTimer = setTimeout(connect, wait);
|
||||
};
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await Promise.all([refreshRead(), refreshActivity()]);
|
||||
// The first start begins at zero, not the latest page. Unread counts
|
||||
// therefore include older public replies even after a long absence.
|
||||
await catchUp(!started);
|
||||
if (!active) return;
|
||||
presenter.flush();
|
||||
started = true; setLoading(false); connect();
|
||||
} catch (failure) {
|
||||
if (!active) return;
|
||||
reportError(failure); setLoading(false); setConnection("offline");
|
||||
startupTimer = setTimeout(() => { void start(); }, 10_000);
|
||||
}
|
||||
};
|
||||
const maintain = async (foreground = false) => {
|
||||
if (!active || !started || maintaining || authFailed) return;
|
||||
maintaining = true;
|
||||
try {
|
||||
await refreshRead();
|
||||
await readWriter.flush();
|
||||
if (!socketAuthenticated) {
|
||||
await Promise.all([catchUp(), refreshActivity()]);
|
||||
} else if (foreground) await catchUp();
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
if (Date.now() - lastPacket > 60_000) socket.close();
|
||||
else socket.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
} catch (failure) { reportError(failure); }
|
||||
finally { maintaining = false; }
|
||||
};
|
||||
const focus = () => { if (document.visibilityState === "visible") void maintain(true); };
|
||||
const interval = setInterval(() => { void maintain(); }, 15_000);
|
||||
window.addEventListener("focus", focus);
|
||||
document.addEventListener("visibilitychange", focus);
|
||||
void start();
|
||||
return () => {
|
||||
active = false; controller.abort();
|
||||
presenter.dispose();
|
||||
clearInterval(interval); clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
||||
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
|
||||
socket?.close();
|
||||
if (client.current === api) client.current = null;
|
||||
mark.current = null;
|
||||
};
|
||||
}, [enabled, revision, update]);
|
||||
|
||||
const renderedCursor = room.cursor;
|
||||
const markRead = useCallback(() => {
|
||||
if (!enabled || isLoading) return;
|
||||
// This callback belongs to this rendered snapshot. Reading current.current
|
||||
// here would incorrectly mark a socket message that has not rendered yet.
|
||||
mark.current?.(renderedCursor);
|
||||
}, [enabled, isLoading, renderedCursor]);
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
||||
await outbox.current.send(content);
|
||||
}, [enabled]);
|
||||
const retryMessage = useCallback(async (messageId: string) => {
|
||||
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
||||
await outbox.current.retry(messageId);
|
||||
}, [enabled]);
|
||||
const retry = useCallback(() => { setRevision(value => value + 1); }, []);
|
||||
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
|
||||
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
|
||||
isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
|
||||
}
|
||||
+29
-5
@@ -194,11 +194,35 @@ textarea {
|
||||
0 14px 36px hsl(240 80% 2% / 0.28);
|
||||
}
|
||||
|
||||
.chat-user-message {
|
||||
/* Preserve the original bubble radius independently of the square UI theme.
|
||||
Browsers without corner-shape support fall back to circular corners. */
|
||||
border-radius: 0.75rem;
|
||||
corner-shape: squircle;
|
||||
/* Shared by regular chats and the persistent agent transcript. */
|
||||
.message-bubble {
|
||||
min-width: 0;
|
||||
padding: 13px 17px;
|
||||
border: 1px solid hsl(256 37% 57% / .18);
|
||||
border-radius: 18px 18px 18px 5px;
|
||||
background: linear-gradient(150deg, hsl(251 36% 15% / .93), hsl(244 35% 11% / .93));
|
||||
box-shadow: 0 3px 12px hsl(239 55% 2% / .13);
|
||||
font-size: 14px;
|
||||
line-height: 1.68;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-bubble--user {
|
||||
border-color: hsl(266 80% 72% / .3);
|
||||
border-radius: 18px 18px 5px 18px;
|
||||
background: linear-gradient(135deg, hsl(264 59% 33% / .86), hsl(259 58% 26% / .9));
|
||||
color: hsl(265 55% 97%);
|
||||
}
|
||||
|
||||
.message-bubble .md-content {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.message-bubble {
|
||||
padding: 11px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-call-stack-shell {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { Message } from "./api";
|
||||
|
||||
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
|
||||
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
|
||||
export type AgentOutboxRow = {
|
||||
id: string; requestId: string; content: string; createdAt: string;
|
||||
status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
|
||||
};
|
||||
export type AgentOutboxStorage = {
|
||||
read(): unknown; write(rows: AgentOutboxRow[]): void;
|
||||
legacyRows?(): unknown; legacyIds?(): unknown;
|
||||
};
|
||||
type Receipt = { status: string; messageId?: string };
|
||||
type RoomMessages = { messages: readonly Message[]; activity?: { queuedMessages?: readonly Message[] } | null };
|
||||
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 240);
|
||||
const info = (message: Message) => message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
|
||||
? message.metadata as Record<string, unknown> : {};
|
||||
const idFor = (requestId: string) => `inbound:sybil:${requestId}`;
|
||||
const validRequestId = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256;
|
||||
|
||||
/** Local rows use the server's identity from their first render through final echo. */
|
||||
export function createAgentOptimisticOutbox(options: {
|
||||
submit(content: string, requestId: string): Promise<Receipt>;
|
||||
storage: AgentOutboxStorage;
|
||||
changed(): void;
|
||||
newId?: () => string;
|
||||
now?: () => number;
|
||||
}) {
|
||||
const newId = options.newId || (() => crypto.randomUUID());
|
||||
const now = options.now || Date.now;
|
||||
const rows = new Map<string, AgentOutboxRow>();
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
const rejectedStorage = new Set<string>();
|
||||
const confirmedEchoes = new Map<string, Message>();
|
||||
let latestTime = 0;
|
||||
const timestamp = () => {
|
||||
latestTime = Math.max(now(), latestTime + 1);
|
||||
return new Date(latestTime).toISOString();
|
||||
};
|
||||
const restored = options.storage.read();
|
||||
if (Array.isArray(restored)) {
|
||||
for (const value of restored.slice(0, 100)) {
|
||||
if (!value || typeof value.content !== "string" || !value.content.trim() || !validRequestId(value.requestId)) continue;
|
||||
const time = typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt)) ? value.createdAt : timestamp();
|
||||
latestTime = Math.max(latestTime, Date.parse(time));
|
||||
const status: AgentDeliveryStatus = ["failed", "queued", "complete", "unconfirmed"].includes(value.status) ? value.status : "unconfirmed";
|
||||
const row: AgentOutboxRow = { id: idFor(value.requestId), requestId: value.requestId, content: value.content, createdAt: time,
|
||||
status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
|
||||
...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) };
|
||||
rows.set(row.id, row);
|
||||
}
|
||||
} else {
|
||||
// Join the former UI-only failed cards to the old sender's receipt keys.
|
||||
// Neither representation is retried automatically during migration.
|
||||
const oldRows = options.storage.legacyRows?.();
|
||||
const oldIds = options.storage.legacyIds?.();
|
||||
const ids = new Map<string, string>();
|
||||
if (Array.isArray(oldIds)) for (const value of oldIds) {
|
||||
if (value && typeof value.content === "string" && validRequestId(value.requestId)) ids.set(value.content, value.requestId);
|
||||
}
|
||||
const contents = new Map<string, { content: string; status?: string; error?: string }>();
|
||||
if (Array.isArray(oldRows)) for (const value of oldRows) {
|
||||
if (value && typeof value.content === "string" && value.content.trim()) contents.set(value.content, value);
|
||||
}
|
||||
for (const content of ids.keys()) if (!contents.has(content)) contents.set(content, { content });
|
||||
for (const value of [...contents.values()].slice(0, 100)) {
|
||||
const requestId = ids.get(value.content) || newId();
|
||||
rows.set(idFor(requestId), { id: idFor(requestId), requestId, content: value.content, createdAt: timestamp(),
|
||||
status: value.status === "failed" ? "failed" : "unconfirmed", seenOnServer: false, confirmed: false,
|
||||
error: typeof value.error === "string" && value.error ? value.error.slice(0, 240)
|
||||
: ids.has(value.content) ? "Delivery was interrupted; retry to confirm." : "Restored draft; check the conversation before retrying." });
|
||||
}
|
||||
}
|
||||
const persist = () => {
|
||||
const pending = [...rows.values()].filter(row => !row.confirmed && !rejectedStorage.has(row.id));
|
||||
if (pending.length > 100 || JSON.stringify(pending).length > 2 * 1024 * 1024) throw new Error("Too many pending agent messages. Retry existing messages before sending more.");
|
||||
options.storage.write(pending.map(row => ({ ...row })));
|
||||
};
|
||||
const saveBestEffort = () => { try { persist(); } catch { /* Existing receipt IDs stay in memory; acknowledged messages remain sent. */ } };
|
||||
const publish = () => options.changed();
|
||||
const dispatch = (row: AgentOutboxRow): Promise<void> => {
|
||||
const running = inFlight.get(row.id);
|
||||
if (running) return running;
|
||||
if (row.confirmed || row.seenOnServer) return Promise.resolve();
|
||||
row.status = "sending"; delete row.error;
|
||||
rejectedStorage.delete(row.id);
|
||||
try { persist(); }
|
||||
catch (error) {
|
||||
// A refused new draft must not prevent older persisted drafts from
|
||||
// draining their outbox. The refused row remains visibly retryable.
|
||||
rejectedStorage.add(row.id);
|
||||
row.status = "failed"; row.error = errorText(error); publish();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// Both durable local storage and the visible row exist before POST begins.
|
||||
publish();
|
||||
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId)).then(receipt => {
|
||||
if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity.");
|
||||
if (!row.confirmed) {
|
||||
row.seenOnServer = true;
|
||||
row.status = receipt.status === "queued" ? "queued" : "complete";
|
||||
delete row.error; saveBestEffort(); publish();
|
||||
}
|
||||
}).catch(error => {
|
||||
// A canonical echo wins over a lost HTTP response. The message has already
|
||||
// been admitted, so a transport error must not paint it failed or resend it.
|
||||
if (row.confirmed || row.seenOnServer) return;
|
||||
row.status = "failed"; row.error = errorText(error); saveBestEffort(); publish();
|
||||
throw error;
|
||||
}).finally(() => { inFlight.delete(row.id); });
|
||||
inFlight.set(row.id, pending);
|
||||
return pending;
|
||||
};
|
||||
// Complete migration even when the user leaves restored drafts untouched:
|
||||
// their newly assigned identity/time must survive the next reload too.
|
||||
saveBestEffort();
|
||||
return {
|
||||
send(content: string): Promise<void> {
|
||||
if (!content.trim()) return Promise.reject(new Error("Enter a message."));
|
||||
const requestId = newId();
|
||||
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
|
||||
rows.set(row.id, row);
|
||||
return dispatch(row);
|
||||
},
|
||||
retry(messageId: string): Promise<void> {
|
||||
const row = rows.get(messageId);
|
||||
return row ? dispatch(row) : Promise.reject(new Error("This message is no longer pending."));
|
||||
},
|
||||
reconcile(room: RoomMessages): void {
|
||||
const committed = new Map(room.messages.map(message => [message.id, message]));
|
||||
const queued = new Set((room.activity?.queuedMessages || []).map(message => message.id));
|
||||
let changed = false;
|
||||
for (const row of rows.values()) {
|
||||
if (committed.has(row.id)) {
|
||||
confirmedEchoes.set(row.id, committed.get(row.id)!);
|
||||
if (!row.confirmed || row.status !== "complete" || row.error) {
|
||||
row.confirmed = true; row.seenOnServer = true; row.status = "complete"; delete row.error; changed = true;
|
||||
}
|
||||
} else if (!row.confirmed && queued.has(row.id) && (row.status !== "queued" || !row.seenOnServer || row.error)) {
|
||||
row.seenOnServer = true; row.status = "queued"; delete row.error; changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) { saveBestEffort(); publish(); }
|
||||
},
|
||||
display(serverMessages: readonly Message[]): Message[] {
|
||||
const displayed = new Map(serverMessages.map(message => {
|
||||
const row = rows.get(message.id);
|
||||
if (!row) return [message.id, message] as const;
|
||||
const serverInfo = info(message);
|
||||
const status = serverInfo.status === "complete" || row.confirmed ? "complete" : row.status;
|
||||
return [message.id, { ...message, createdAt: row.createdAt,
|
||||
metadata: { ...serverInfo, status, ...(row.error && status !== "complete" ? { error: row.error } : {}) } }] as const;
|
||||
}));
|
||||
for (const row of rows.values()) if (!displayed.has(row.id)) {
|
||||
const echo = confirmedEchoes.get(row.id);
|
||||
if (row.confirmed && !echo) continue;
|
||||
displayed.set(row.id, echo ? { ...echo, createdAt: row.createdAt } : {
|
||||
id: row.id, createdAt: row.createdAt, role: "user", content: row.content, name: "buzzert",
|
||||
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.error ? { error: row.error } : {}) },
|
||||
});
|
||||
}
|
||||
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
},
|
||||
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { AgentRoomEvent, AgentRoomState } from './agent-room';
|
||||
import type { Message } from './api';
|
||||
|
||||
type Scheduler = {
|
||||
now(): number;
|
||||
schedule(callback: () => void, delay: number): unknown;
|
||||
cancel(handle: unknown): void;
|
||||
};
|
||||
export type AgentRoomPresenterOptions = {
|
||||
initial: AgentRoomState;
|
||||
reduceEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState;
|
||||
/** Publish room and transcript typing together in one UI state update. */
|
||||
publish(state: AgentRoomState, isTyping: boolean): void;
|
||||
batchMs?: number;
|
||||
typingDelayMs?: number;
|
||||
clock?: Scheduler;
|
||||
};
|
||||
const quietTools = new Set(['user_message', 'notify_parent', 'clock_wait', 'event_decision']);
|
||||
const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object'
|
||||
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
|
||||
const entryId = (message: Message): bigint | undefined => {
|
||||
const value = data(message).entryId;
|
||||
return (typeof value === 'string' && /^\d+$/.test(value)) || (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
|
||||
? BigInt(value) : undefined;
|
||||
};
|
||||
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
|
||||
* 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.
|
||||
* Actual activity remains untouched for the sidebar, workers, and queue display.
|
||||
*/
|
||||
export function createAgentRoomPresenter(options: AgentRoomPresenterOptions) {
|
||||
const clock: Scheduler = options.clock || {
|
||||
now: () => Date.now(),
|
||||
schedule: (callback, delay) => setTimeout(callback, delay),
|
||||
cancel: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
};
|
||||
const batchMs = options.batchMs ?? 100;
|
||||
const typingDelayMs = options.typingDelayMs ?? 250;
|
||||
let state = options.initial;
|
||||
let stopped = false;
|
||||
let batchTimer: unknown;
|
||||
let typingTimer: unknown;
|
||||
let suppressTyping = false;
|
||||
let candidateSince: number | undefined;
|
||||
const seenTools = new Set(state.messages.filter(message => message.role === 'tool').map(toolId));
|
||||
for (const tool of state.activity?.tools || []) seenTools.add(tool.id);
|
||||
|
||||
const reconcile = () => {
|
||||
const work = Boolean(state.activity?.isThinking || (state.activity?.backgroundWorkers || 0) > 0);
|
||||
if (!work || suppressTyping) candidateSince = undefined;
|
||||
else if (candidateSince === undefined) candidateSince = clock.now();
|
||||
};
|
||||
const clearTypingTimer = () => { if (typingTimer !== undefined) clock.cancel(typingTimer); typingTimer = undefined; };
|
||||
const flush = () => {
|
||||
if (stopped) return;
|
||||
if (batchTimer !== undefined) clock.cancel(batchTimer);
|
||||
batchTimer = undefined; clearTypingTimer();
|
||||
reconcile();
|
||||
const isTyping = candidateSince !== undefined && clock.now() - candidateSince >= typingDelayMs;
|
||||
options.publish(state, isTyping);
|
||||
if (candidateSince !== undefined && !isTyping) typingTimer = clock.schedule(flush, Math.max(0, typingDelayMs - (clock.now() - candidateSince)));
|
||||
};
|
||||
const schedule = () => {
|
||||
reconcile();
|
||||
// A fixed maximum batch window avoids starving publication during bursts.
|
||||
if (batchTimer === undefined) batchTimer = clock.schedule(flush, batchMs);
|
||||
};
|
||||
const release = () => { suppressTyping = false; };
|
||||
const latch = () => {
|
||||
suppressTyping = true;
|
||||
candidateSince = undefined;
|
||||
clearTypingTimer();
|
||||
};
|
||||
|
||||
return {
|
||||
/** replay:true is for initial historical loading, not ongoing catch-up. */
|
||||
push(event: AgentRoomEvent, hints: { replay?: boolean } = {}) {
|
||||
if (stopped) return;
|
||||
const before = state;
|
||||
const next = options.reduceEvent(before, event); // Validate/reduce before touching presentation state.
|
||||
state = next;
|
||||
if (event.type === 'messages') {
|
||||
const existing = new Set(before.messages.map(message => message.id));
|
||||
const fresh = event.messages.filter(message => !existing.has(message.id) && (entryId(message) ?? -1n) > BigInt(before.cursor))
|
||||
.sort((left, right) => (entryId(left) || 0n) < (entryId(right) || 0n) ? -1 : 1);
|
||||
if (!hints.replay) for (const message of fresh) {
|
||||
const info = data(message);
|
||||
if (message.role === 'assistant' && info.kind === 'message' && message.content.trim()) latch();
|
||||
else if (suppressTyping && message.role === 'user') release();
|
||||
else if (suppressTyping && message.role === 'tool' && info.status === 'initiated'
|
||||
&& !quietTools.has(String(info.toolName || message.name)) && !seenTools.has(toolId(message))) release();
|
||||
if (message.role === 'tool') seenTools.add(toolId(message));
|
||||
}
|
||||
for (const message of event.messages) if (message.role === 'tool') seenTools.add(toolId(message));
|
||||
} else if (event.type === 'activity') {
|
||||
const activity = event.activity;
|
||||
if (suppressTyping && (!activity.isThinking
|
||||
|| activity.tools.some(tool => !seenTools.has(tool.id) && !quietTools.has(tool.name)))) release();
|
||||
for (const tool of activity.tools) seenTools.add(tool.id);
|
||||
}
|
||||
schedule();
|
||||
},
|
||||
/** Read-position updates share the pending snapshot; they never lose queued frames. */
|
||||
update(change: (state: AgentRoomState) => AgentRoomState) {
|
||||
if (stopped) return;
|
||||
state = change(state); schedule();
|
||||
},
|
||||
flush,
|
||||
dispose() {
|
||||
stopped = true;
|
||||
if (batchTimer !== undefined) clock.cancel(batchTimer);
|
||||
batchTimer = undefined; clearTypingTimer();
|
||||
// Unpublished input is deliberately discarded; reconnect replays from the
|
||||
// last published cursor, so neither delivery nor read tracking gets ahead.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { Message } from "./api";
|
||||
|
||||
export type AgentActivity = {
|
||||
state: "idle" | "thinking" | "tool" | "waiting" | "error";
|
||||
isThinking: boolean;
|
||||
queued: number;
|
||||
backgroundWorkers: number;
|
||||
tools: Array<{ id: string; name: string; status: string; output?: string }>;
|
||||
queuedMessages: Message[];
|
||||
wait?: { due: number; reason?: string };
|
||||
error?: string;
|
||||
};
|
||||
export type AgentMessagesPage = {
|
||||
conversationId: string; messages: Message[]; cursor: string | null;
|
||||
beforeCursor: string | null; hasMore: boolean;
|
||||
};
|
||||
export type AgentDescriptor = {
|
||||
id: string; title: string; model: string; activity: AgentActivity;
|
||||
websocket: string; protocol: string;
|
||||
};
|
||||
export type AgentReadState = { cursor: string; updatedAt: string | null };
|
||||
export type AgentRoomState = {
|
||||
conversationId: string | null; messages: Message[]; cursor: string;
|
||||
readCursor: string; activity: AgentActivity | null;
|
||||
};
|
||||
export type AgentRoomEvent =
|
||||
| ({ type: "messages" } & AgentMessagesPage)
|
||||
| { type: "activity"; conversationId: string; activity: AgentActivity }
|
||||
| { type: "ready"; conversationId?: string }
|
||||
| { type: "pong" | "heartbeat" }
|
||||
| { type: "error"; error?: string; message?: string };
|
||||
|
||||
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : undefined;
|
||||
const defined = (value: Record<string, unknown>) => Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
||||
export function normalizeAgentCursor(value: unknown): string {
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("Invalid agent message cursor.");
|
||||
return BigInt(value).toString();
|
||||
}
|
||||
export function advanceAgentCursor(current: string, incoming: string): string {
|
||||
const a = normalizeAgentCursor(current), b = normalizeAgentCursor(incoming);
|
||||
return BigInt(a) >= BigInt(b) ? a : b;
|
||||
}
|
||||
function metadata(message: Message) { return object(message.metadata); }
|
||||
function messageCursor(message: Message): string | null {
|
||||
const entryId = metadata(message)?.entryId;
|
||||
if (typeof entryId === "number" && Number.isSafeInteger(entryId) && entryId >= 0) return String(entryId);
|
||||
return typeof entryId === "string" && /^\d+$/.test(entryId) ? normalizeAgentCursor(entryId) : null;
|
||||
}
|
||||
function mergeMessage(previous: Message, incoming: Message): Message {
|
||||
const prior = metadata(previous), next = metadata(incoming);
|
||||
// A delayed activity snapshot must not demote an already placed user message.
|
||||
if (prior?.status === "complete" && next?.status === "queued") return previous;
|
||||
if (prior?.kind !== "tool_call" || next?.kind !== "tool_call") return { ...previous, ...incoming };
|
||||
const terminal = prior.status === "completed" || prior.status === "failed";
|
||||
const staleStart = terminal && next.status === "initiated";
|
||||
const merged = staleStart ? { ...defined(next), ...defined(prior) } : { ...defined(prior), ...defined(next) };
|
||||
if (!next.startedAt && prior.startedAt) merged.startedAt = prior.startedAt;
|
||||
const oldArgs = object(prior.args), newArgs = object(next.args);
|
||||
if (oldArgs && (!newArgs || Object.keys(newArgs).length === 0)) merged.args = oldArgs;
|
||||
return { ...previous, ...incoming, ...(staleStart ? { content: previous.content, name: previous.name } : {}),
|
||||
createdAt: previous.createdAt < incoming.createdAt ? previous.createdAt : incoming.createdAt, metadata: merged };
|
||||
}
|
||||
|
||||
/** Replays and separate tool-start/result pages update stable rows instead of duplicating them. */
|
||||
export function mergeAgentMessages(previous: readonly Message[], incoming: readonly Message[]): Message[] {
|
||||
const messages = new Map(previous.map(message => [message.id, message]));
|
||||
for (const message of incoming) {
|
||||
const prior = messages.get(message.id);
|
||||
messages.set(message.id, prior ? mergeMessage(prior, message) : message);
|
||||
}
|
||||
return [...messages.values()].sort((a, b) => {
|
||||
const byTime = a.createdAt.localeCompare(b.createdAt);
|
||||
if (byTime) return byTime;
|
||||
const left = messageCursor(a), right = messageCursor(b);
|
||||
if (left !== null && right !== null && left !== right) return BigInt(left) < BigInt(right) ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
export const createAgentRoomState = (): AgentRoomState => ({
|
||||
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
||||
});
|
||||
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
|
||||
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
|
||||
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
|
||||
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
|
||||
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
|
||||
}
|
||||
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
|
||||
return { ...state, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
||||
}
|
||||
export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState {
|
||||
if (event.type === "messages") return applyAgentPage(state, event);
|
||||
if (event.type !== "activity") return state;
|
||||
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
|
||||
return { ...state, conversationId: event.conversationId, activity: event.activity };
|
||||
}
|
||||
export function agentUnreadCount(state: AgentRoomState): number {
|
||||
return state.messages.filter(message => {
|
||||
if (message.role !== "assistant" || metadata(message)?.kind !== "message") return false;
|
||||
const cursor = messageCursor(message);
|
||||
return cursor !== null && BigInt(cursor) > BigInt(state.readCursor);
|
||||
}).length;
|
||||
}
|
||||
export function agentDisplayMessages(state: AgentRoomState): Message[] {
|
||||
const committed = new Set(state.messages.map(message => message.id));
|
||||
return mergeAgentMessages(state.messages, (state.activity?.queuedMessages || []).filter(message => !committed.has(message.id)));
|
||||
}
|
||||
export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
|
||||
const event = object(JSON.parse(raw));
|
||||
if (!event || typeof event.type !== "string") throw new Error("Invalid agent room frame.");
|
||||
if (!["messages", "activity", "ready", "pong", "heartbeat", "error"].includes(event.type)) return null;
|
||||
if (event.type === "messages") {
|
||||
if (typeof event.conversationId !== "string" || !Array.isArray(event.messages) || typeof event.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
|
||||
if (event.cursor !== null) normalizeAgentCursor(event.cursor);
|
||||
for (const item of event.messages) {
|
||||
const message = object(item);
|
||||
if (!message || typeof message.id !== "string" || typeof message.content !== "string" || typeof message.createdAt !== "string"
|
||||
|| !["system", "user", "assistant", "tool"].includes(String(message.role))) throw new Error("Invalid agent message.");
|
||||
}
|
||||
}
|
||||
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
|
||||
return event as AgentRoomEvent;
|
||||
}
|
||||
export function agentAuthFrame(token: string | null, cursor: string): string {
|
||||
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor) });
|
||||
}
|
||||
|
||||
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string };
|
||||
export type AgentRoomClientOptions = { baseUrl?: string; token?: () => string | null; fetch?: typeof globalThis.fetch };
|
||||
export class AgentRoomClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetcher: typeof globalThis.fetch;
|
||||
private readonly token: () => string | null;
|
||||
constructor(options: AgentRoomClientOptions = {}) {
|
||||
this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, "");
|
||||
this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis);
|
||||
this.token = options.token || (() => null);
|
||||
}
|
||||
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = this.token();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
const response = await this.fetcher(`${this.baseUrl}/v1/agent${path}`, { ...init, headers });
|
||||
let value: unknown;
|
||||
try { value = await response.json(); }
|
||||
catch { throw new Error(`Agent API returned ${response.status} without a JSON response.`); }
|
||||
if (!response.ok) {
|
||||
const error = object(value);
|
||||
throw new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
descriptor(signal?: AbortSignal): Promise<AgentDescriptor> { return this.request("", { signal }); }
|
||||
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
||||
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
|
||||
}
|
||||
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
||||
markRead(cursor: string, signal?: AbortSignal): Promise<AgentReadState> {
|
||||
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor) }), signal });
|
||||
}
|
||||
async submit(content: string, requestId: string): Promise<AgentSubmission> {
|
||||
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId }) }));
|
||||
// An HTTP success alone does not acknowledge delivery. Keep the original
|
||||
// outbox receipt key retryable if a proxy or upstream returns malformed data.
|
||||
if (!receipt || typeof receipt.id !== "number" || !Number.isSafeInteger(receipt.id) || receipt.id <= 0
|
||||
|| typeof receipt.status !== "string" || !["queued", "placed", "done", "unanswered"].includes(receipt.status)
|
||||
|| receipt.messageId !== `inbound:sybil:${requestId}`) throw new Error("Agent API returned an invalid message receipt. Retry to confirm delivery.");
|
||||
return receipt as AgentSubmission;
|
||||
}
|
||||
websocketUrl(pageUrl: string): string {
|
||||
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read every newer page, including pages containing only invisible internal entries. */
|
||||
export async function syncAgentHistory(client: Pick<AgentRoomClient, "messages">, from: string,
|
||||
apply: (page: AgentMessagesPage) => void, signal?: AbortSignal): Promise<void> {
|
||||
let cursor = normalizeAgentCursor(from);
|
||||
while (!signal?.aborted) {
|
||||
const page = await client.messages(cursor, signal);
|
||||
if (signal?.aborted) return;
|
||||
const next = normalizeAgentCursor(page.cursor ?? cursor);
|
||||
if (BigInt(next) < BigInt(cursor) || (page.hasMore && next === cursor)) throw new Error("Agent history did not advance its cursor.");
|
||||
apply(page);
|
||||
cursor = next;
|
||||
if (!page.hasMore) return;
|
||||
}
|
||||
}
|
||||
|
||||
// Retained only to migrate receipt IDs from the former admission sender.
|
||||
export const AGENT_ADMISSION_STORAGE_KEY = "sybil.agent.pending-admissions.v1";
|
||||
|
||||
/** Only successfully persisted, explicitly observed cursors become read state. */
|
||||
export function createAgentReadWriter(options: {
|
||||
current: () => string; save: (cursor: string) => Promise<AgentReadState>; apply: (read: AgentReadState) => void;
|
||||
}) {
|
||||
let desired = "0";
|
||||
let running: Promise<void> | undefined;
|
||||
const flush = (): Promise<void> => {
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
while (BigInt(desired) > BigInt(options.current())) {
|
||||
const captured = desired;
|
||||
const saved = await options.save(captured);
|
||||
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
|
||||
options.apply(saved);
|
||||
}
|
||||
})().finally(() => { running = undefined; });
|
||||
return running;
|
||||
};
|
||||
return { request(cursor: string) { desired = advanceAgentCursor(desired, cursor); return flush(); }, flush };
|
||||
}
|
||||
@@ -230,6 +230,14 @@ export function getConfiguredToken() {
|
||||
return ENV_ADMIN_TOKEN;
|
||||
}
|
||||
|
||||
export function getAuthToken() {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
export function getApiBaseUrl() {
|
||||
return API_BASE_URL;
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
authToken = token?.trim() || null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||
export type HistorySelection = { kind: "chat" | "search"; id: string };
|
||||
export type SidebarSelection = HistorySelection | { kind: "agent"; id: "root" };
|
||||
|
||||
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
|
||||
type WorkspaceSelectionItem = { type: HistorySelection["kind"]; id: string };
|
||||
|
||||
export function retainSelectedHistoryItem<T extends WorkspaceSelectionItem>(
|
||||
items: T[], selection: SidebarSelection | null, retained: T | null
|
||||
@@ -24,6 +25,8 @@ export function resolveSidebarSelectionAfterRefresh(
|
||||
): SidebarSelection | null {
|
||||
const hasItem = (candidate: SidebarSelection | null | undefined) => {
|
||||
if (!candidate) return false;
|
||||
// The persistent agent is always available, independent of saved history.
|
||||
if (candidate.kind === "agent") return true;
|
||||
return workspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
||||
|
||||
const canonical = (id, content = "Hello", time = "2026-10-14T12:01:00.000Z") => ({
|
||||
id, content, createdAt: time, role: "user", name: "buzzert",
|
||||
metadata: { kind: "message", entryId: 123, status: "complete", channel: "sybil" },
|
||||
});
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
function fixture(submit, legacy = {}) {
|
||||
let stored = null;
|
||||
let serial = 0;
|
||||
const changes = [];
|
||||
let box;
|
||||
const storage = { read: () => structuredClone(stored), write: rows => { stored = structuredClone(rows); }, ...legacy };
|
||||
const open = () => {
|
||||
box = createAgentOptimisticOutbox({ submit, storage, newId: () => `request-${++serial}`, now: () => Date.parse("2026-10-14T12:00:00.000Z"), changed: () => changes.push(box?.snapshot()) });
|
||||
return box;
|
||||
};
|
||||
return { open, changes, stored: () => stored, storage };
|
||||
}
|
||||
|
||||
test("send publishes and persists one canonical row before POST, retaining it through receipt and echo", async () => {
|
||||
const network = deferred(); let called = false;
|
||||
const state = fixture(async (_content, requestId) => { called = true; assert.equal(requestId, "request-1"); return network.promise; });
|
||||
const box = state.open();
|
||||
const done = box.send("Hello");
|
||||
const first = box.display([])[0];
|
||||
assert.equal(called, false, "network starts after synchronous local publication");
|
||||
assert.equal(first.id, "inbound:sybil:request-1");
|
||||
assert.equal(first.metadata.status, "sending");
|
||||
assert.equal(state.stored()[0].id, first.id);
|
||||
assert.equal(state.changes.length, 1);
|
||||
network.resolve({ status: "queued", messageId: first.id }); await done;
|
||||
assert.equal(box.display([])[0].metadata.status, "queued");
|
||||
assert.equal(box.display([])[0].createdAt, first.createdAt);
|
||||
const echo = canonical(first.id);
|
||||
box.reconcile({ messages: [echo] });
|
||||
const displayed = box.display([echo]);
|
||||
assert.equal(displayed.length, 1);
|
||||
assert.equal(displayed[0].id, first.id);
|
||||
assert.equal(displayed[0].createdAt, first.createdAt, "server receipt time cannot reorder the optimistic bubble");
|
||||
assert.equal(displayed[0].metadata.status, "complete");
|
||||
assert.deepEqual(state.stored(), []);
|
||||
assert.equal(box.display([]).length, 1, "even a stale display snapshot cannot create an acknowledgment gap");
|
||||
});
|
||||
|
||||
test("canonical echo before an HTTP failure wins and never paints a delivered bubble failed", async () => {
|
||||
const network = deferred(); const state = fixture(() => network.promise); const box = state.open();
|
||||
const done = box.send("Hello"); const row = box.display([])[0];
|
||||
const echo = canonical(row.id);
|
||||
box.reconcile({ messages: [echo] });
|
||||
network.reject(new Error("response lost"));
|
||||
await done;
|
||||
assert.equal(box.display([echo])[0].metadata.status, "complete");
|
||||
assert.equal(box.display([echo])[0].metadata.error, undefined);
|
||||
assert.deepEqual(state.stored(), []);
|
||||
});
|
||||
|
||||
test("queue echo before HTTP acknowledgment preserves one row when the queue snapshot disappears", async () => {
|
||||
const network = deferred(); const state = fixture(() => network.promise); const box = state.open();
|
||||
const done = box.send("Hello"); const row = box.display([])[0];
|
||||
const queued = { ...canonical(row.id), metadata: { kind: "message", status: "queued" } };
|
||||
box.reconcile({ messages: [], activity: { queuedMessages: [queued] } });
|
||||
assert.equal(box.display([queued]).length, 1);
|
||||
box.reconcile({ messages: [], activity: { queuedMessages: [] } });
|
||||
assert.equal(box.display([])[0].id, row.id, "queue-to-history transfer must not hide the bubble");
|
||||
network.reject(new Error("HTTP acknowledgment lost")); await done;
|
||||
assert.equal(box.display([])[0].metadata.status, "queued");
|
||||
});
|
||||
|
||||
test("failed retry and reload preserve the message ID, timestamp, and original receipt key", async () => {
|
||||
const requests = []; let fail = true;
|
||||
const state = fixture(async (content, requestId) => {
|
||||
requests.push({ content, requestId });
|
||||
if (fail) throw new Error("network down");
|
||||
return { status: "placed", messageId: `inbound:sybil:${requestId}` };
|
||||
});
|
||||
let box = state.open();
|
||||
await assert.rejects(box.send("Hello"), /network down/);
|
||||
const failed = box.display([])[0];
|
||||
assert.equal(failed.metadata.status, "failed");
|
||||
assert.equal(failed.metadata.error, "network down");
|
||||
box = state.open();
|
||||
assert.equal(requests.length, 1, "restoring drafts never sends automatically");
|
||||
assert.equal(box.display([])[0].metadata.status, "failed");
|
||||
assert.equal(box.display([])[0].createdAt, failed.createdAt);
|
||||
fail = false;
|
||||
const retry = box.retry(failed.id);
|
||||
assert.equal(box.display([])[0].metadata.status, "sending");
|
||||
await retry;
|
||||
assert.deepEqual(requests.map(row => row.requestId), ["request-1", "request-1"]);
|
||||
assert.equal(box.display([]).length, 1);
|
||||
assert.equal(box.display([])[0].metadata.status, "complete");
|
||||
assert.equal(box.display([])[0].createdAt, failed.createdAt);
|
||||
});
|
||||
|
||||
test("in-flight reload becomes unconfirmed and two identical sends remain independent rows", async () => {
|
||||
const first = deferred(), second = deferred(); let calls = 0;
|
||||
const state = fixture(() => ++calls === 1 ? first.promise : second.promise); const box = state.open();
|
||||
const send1 = box.send("Same content"), send2 = box.send("Same content");
|
||||
const rows = box.display([]);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.notEqual(rows[0].id, rows[1].id);
|
||||
assert.ok(rows[0].createdAt < rows[1].createdAt, "same-millisecond sends retain insertion order");
|
||||
const reloaded = state.open();
|
||||
assert.deepEqual(reloaded.display([]).map(row => row.metadata.status), ["unconfirmed", "unconfirmed"]);
|
||||
assert.deepEqual(reloaded.display([]).map(row => row.id), rows.map(row => row.id));
|
||||
first.resolve({ status: "queued" }); second.resolve({ status: "queued" });
|
||||
await Promise.all([send1, send2]);
|
||||
});
|
||||
|
||||
test("legacy failed cards join their saved receipt keys without automatic delivery", () => {
|
||||
const state = fixture(() => assert.fail("migration must not submit"), {
|
||||
legacyRows: () => [{ content: "old message", status: "failed", error: "old error" }],
|
||||
legacyIds: () => [{ content: "old message", requestId: "old-request" }],
|
||||
});
|
||||
const box = state.open();
|
||||
assert.equal(box.display([])[0].id, "inbound:sybil:old-request");
|
||||
assert.equal(box.display([])[0].metadata.status, "failed");
|
||||
assert.equal(box.display([])[0].metadata.error, "old error");
|
||||
const migrated = state.stored()[0];
|
||||
assert.equal(migrated.id, "inbound:sybil:old-request");
|
||||
assert.equal(state.open().display([])[0].createdAt, migrated.createdAt, "migration is saved before any retry");
|
||||
});
|
||||
|
||||
test("storage failures still leave a failed inline row and prevent untracked POST", async () => {
|
||||
let calls = 0, visible = 0;
|
||||
const box = createAgentOptimisticOutbox({ submit: async () => { calls++; return { status: "queued" }; },
|
||||
storage: { read: () => null, write: () => { throw new Error("storage unavailable"); } },
|
||||
newId: () => "request", changed: () => visible++,
|
||||
});
|
||||
const sending = box.send("Do not lose this text");
|
||||
assert.equal(box.display([])[0].metadata.status, "failed");
|
||||
assert.equal(box.display([])[0].content, "Do not lose this text");
|
||||
assert.equal(visible, 1);
|
||||
await assert.rejects(sending, /storage unavailable/);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAgentEvent, applyAgentReadState, createAgentRoomState } from '../src/lib/agent-room.ts';
|
||||
import { createAgentRoomPresenter } from '../src/lib/agent-room-presentation.ts';
|
||||
|
||||
const message = (entry, role = 'assistant', extra = {}) => ({ id: `entry:${entry}`, createdAt: '2026-10-14T00:00:00.000Z', role,
|
||||
content: `Message ${entry}`, metadata: { kind: 'message', entryId: entry, status: 'complete' }, ...extra });
|
||||
const page = (cursor, messages) => ({ type: 'messages', conversationId: '1', messages, cursor: String(cursor), beforeCursor: '0', hasMore: false });
|
||||
const activity = (isThinking, extra = {}) => ({ type: 'activity', conversationId: '1', activity: {
|
||||
state: isThinking ? 'thinking' : 'idle', isThinking, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [], ...extra,
|
||||
} });
|
||||
function fixture(initial = createAgentRoomState()) {
|
||||
let now = 0, sequence = 0;
|
||||
const tasks = new Map();
|
||||
const frames = [];
|
||||
const clock = { now: () => now, schedule(callback, delay) { const id = ++sequence; tasks.set(id, { at: now + delay, callback }); return id; }, cancel(id) { tasks.delete(id); } };
|
||||
const presenter = createAgentRoomPresenter({ initial, reduceEvent: applyAgentEvent, publish: (room, isTyping) => frames.push({ room, isTyping }), clock });
|
||||
const advance = ms => {
|
||||
const end = now + ms;
|
||||
while (true) {
|
||||
const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next || next[1].at > end) break;
|
||||
now = next[1].at; tasks.delete(next[0]); next[1].callback();
|
||||
}
|
||||
now = end;
|
||||
};
|
||||
return { presenter, advance, frames, last: () => frames.at(-1) };
|
||||
}
|
||||
|
||||
test('a reply and removal of its old thinking indicator publish together even when idle is delayed', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(activity(true)); f.advance(250); assert.equal(f.last().isTyping, true);
|
||||
f.presenter.push(page(2, [message(2)])); f.advance(100);
|
||||
assert.equal(f.last().room.messages.length, 1); assert.equal(f.last().isTyping, false);
|
||||
assert.equal(f.last().room.activity.isThinking, true, 'raw sidebar activity remains unchanged');
|
||||
f.presenter.push(activity(true)); f.advance(1000); assert.equal(f.last().isTyping, false, 'identical busy snapshots cannot revive stale typing');
|
||||
f.presenter.push(activity(false)); f.advance(100);
|
||||
assert.ok(f.frames.every(frame => !frame.room.messages.length || !frame.isTyping));
|
||||
});
|
||||
|
||||
test('split messages/activity frames batch without reply-plus-thinking flashes or transient typing starts', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(activity(true)); f.advance(50);
|
||||
f.presenter.push(page(2, [message(1, 'user'), message(2)])); f.advance(20);
|
||||
f.presenter.push(activity(false)); f.advance(30);
|
||||
assert.equal(f.frames.length, 1); assert.equal(f.last().room.messages.length, 2); assert.equal(f.last().isTyping, false);
|
||||
f.advance(1000); assert.equal(f.frames.length, 1);
|
||||
});
|
||||
|
||||
test('new user work and new tools can start typing again, but delivery tools cannot', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(activity(true)); f.advance(250);
|
||||
f.presenter.push(page(2, [message(2)])); f.advance(100);
|
||||
f.presenter.push(activity(true, { tools: [{ id: 'send-reply', name: 'user_message', status: 'running' }] }));
|
||||
f.advance(300); assert.equal(f.last().isTyping, false);
|
||||
f.presenter.push(page(3, [message(3, 'user')])); f.advance(250); assert.equal(f.last().isTyping, true);
|
||||
f.presenter.push(page(4, [message(4)])); f.advance(100); assert.equal(f.last().isTyping, false);
|
||||
f.presenter.push(activity(true, { tools: [{ id: 'new-browser', name: 'browser', status: 'running' }] }));
|
||||
f.advance(250); assert.equal(f.last().isTyping, true);
|
||||
});
|
||||
|
||||
test('initial history and duplicate replay do not suppress genuine new activity', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(activity(true));
|
||||
f.presenter.push(page(7, [message(5), message(7)]), { replay: true });
|
||||
f.advance(250); assert.equal(f.last().isTyping, true);
|
||||
f.presenter.push(page(7, [message(5), message(7)])); f.advance(100);
|
||||
assert.equal(f.last().isTyping, true); assert.equal(f.last().room.messages.length, 2);
|
||||
f.presenter.push(page(9, [message(9)])); f.advance(100); assert.equal(f.last().isTyping, false);
|
||||
f.presenter.push(activity(false)); f.advance(100);
|
||||
f.presenter.push(activity(true)); f.advance(250); assert.equal(f.last().isTyping, true);
|
||||
f.presenter.push(page(9, [message(9)])); f.advance(100); assert.equal(f.last().isTyping, true);
|
||||
});
|
||||
|
||||
test('independent background work remains visible after the root yields', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(activity(true, { backgroundWorkers: 1 })); f.advance(250);
|
||||
f.presenter.push(page(2, [message(2)])); f.advance(100); assert.equal(f.last().isTyping, false);
|
||||
f.presenter.push(activity(false, { backgroundWorkers: 1 })); f.advance(250);
|
||||
assert.equal(f.last().isTyping, true); assert.equal(f.last().room.activity.isThinking, false);
|
||||
});
|
||||
|
||||
test('bursts preserve users, tools, queued messages, cursor, and read state in bounded publications', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(page(1, [message(1, 'user')])); f.advance(50);
|
||||
const tool = message(2, 'tool', { metadata: { kind: 'tool_call', toolCallId: 'browser-1', toolName: 'browser', status: 'initiated', entryId: 2 } });
|
||||
f.presenter.push(page(2, [tool])); f.advance(40);
|
||||
const queued = message(3, 'user', { metadata: { kind: 'message', status: 'queued' } });
|
||||
f.presenter.push(activity(true, { queued: 1, queuedMessages: [queued] }));
|
||||
f.presenter.update(state => applyAgentReadState(state, { cursor: '1', updatedAt: null })); f.advance(10);
|
||||
assert.equal(f.frames.length, 1); assert.equal(f.last().room.cursor, '2'); assert.equal(f.last().room.readCursor, '1');
|
||||
assert.equal(f.last().room.messages.length, 2); assert.deepEqual(f.last().room.activity.queuedMessages, [queued]);
|
||||
f.presenter.push(page(4, [{ ...tool, metadata: { ...tool.metadata, status: 'completed', entryId: 4 } }]));
|
||||
f.presenter.flush(); assert.equal(f.last().room.messages.length, 2); assert.equal(f.last().room.cursor, '4');
|
||||
});
|
||||
|
||||
test('disposing an unpublished batch leaves delivery and read cursors at the last rendered snapshot', () => {
|
||||
const f = fixture(); f.presenter.push(page(2, [message(2)])); f.presenter.dispose(); f.advance(1000);
|
||||
assert.equal(f.frames.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadWriter,
|
||||
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
|
||||
} from "../src/lib/agent-room.ts";
|
||||
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
||||
|
||||
const message = (id, role = "assistant", extra = {}) => ({
|
||||
id: `entry:${id}`, createdAt: `2026-10-14T10:00:${String(id % 60).padStart(2, "0")}.000Z`,
|
||||
role, content: `message ${id}`, name: role === "assistant" ? "Pi" : "buzzert",
|
||||
metadata: { kind: "message", entryId: id, status: "complete" }, ...extra,
|
||||
});
|
||||
const page = (cursor, messages = [], hasMore = false) => ({
|
||||
conversationId: "1", messages, cursor, beforeCursor: messages.length ? String(messages[0].metadata.entryId) : cursor, hasMore,
|
||||
});
|
||||
const activity = (queuedMessages = []) => ({ state: "idle", isThinking: false, queued: queuedMessages.length, backgroundWorkers: 0, tools: [], queuedMessages });
|
||||
|
||||
test("tool starts and results merge across replay without downgrading completed cards", () => {
|
||||
const start = message(2, "tool", { id: "tool:1:call1", metadata: { kind: "tool_call", entryId: 2, status: "initiated", args: { query: "cookie" }, startedAt: "2026-10-14T10:00:00Z" } });
|
||||
const done = message(3, "tool", { id: start.id, metadata: { kind: "tool_call", entryId: 3, status: "completed", resultPreview: "found" } });
|
||||
const messages = mergeAgentMessages(mergeAgentMessages([], [start]), [done, start]);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].metadata.status, "completed");
|
||||
assert.equal(messages[0].metadata.resultPreview, "found");
|
||||
assert.deepEqual(messages[0].metadata.args, { query: "cookie" });
|
||||
assert.equal(messages[0].metadata.entryId, 3);
|
||||
assert.equal(messages[0].createdAt, start.createdAt);
|
||||
});
|
||||
|
||||
test("initial synchronization reads every page including invisible entries and counts only unread assistant messages", async () => {
|
||||
const requests = [];
|
||||
const responses = [page("2", [message(1), message(2, "user")], true), page("4", [], true), page("7", [message(5), message(6, "tool"), message(7)], false)];
|
||||
let state = createAgentRoomState();
|
||||
await syncAgentHistory({ messages: async after => { requests.push(after); return responses.shift(); } }, "0", update => { state = applyAgentPage(state, update); });
|
||||
assert.deepEqual(requests, ["0", "2", "4"]);
|
||||
assert.equal(state.cursor, "7");
|
||||
assert.equal(agentUnreadCount(state), 3);
|
||||
state = applyAgentReadState(state, { cursor: "5", updatedAt: null });
|
||||
assert.equal(agentUnreadCount(state), 1);
|
||||
state = applyAgentReadState(state, { cursor: "1", updatedAt: null });
|
||||
assert.equal(state.readCursor, "5", "a stale tab response cannot regress read state");
|
||||
assert.equal(agentUnreadCount(state), 1);
|
||||
});
|
||||
|
||||
test("queued user messages keep stable IDs and disappear if withdrawn or promoted", () => {
|
||||
const pending = message(1, "user", { id: "inbound:sybil:request1", metadata: { kind: "message", status: "queued" } });
|
||||
let state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
|
||||
assert.equal(agentDisplayMessages(state).length, 1);
|
||||
assert.equal(state.cursor, "0", "pending rows never advance replay or read cursors");
|
||||
assert.equal(agentUnreadCount(state), 0);
|
||||
state = applyAgentPage(state, page("4", [message(4, "user", { id: pending.id })]));
|
||||
assert.equal(agentDisplayMessages(state).length, 1);
|
||||
assert.equal(agentDisplayMessages(state)[0].metadata.status, "complete");
|
||||
state = applyAgentEvent(state, { type: "activity", conversationId: "1", activity: activity() });
|
||||
assert.equal(agentDisplayMessages(state).length, 1, "committed rows survive queue removal");
|
||||
const onlyPending = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
|
||||
assert.deepEqual(agentDisplayMessages(applyAgentEvent(onlyPending, { type: "activity", conversationId: "1", activity: activity() })), []);
|
||||
});
|
||||
|
||||
test("failed or stalled page delivery preserves the last applied cursor for replay", async () => {
|
||||
let state = createAgentRoomState();
|
||||
let calls = 0;
|
||||
await assert.rejects(syncAgentHistory({ messages: async () => {
|
||||
if (++calls === 1) return page("10", [message(10)], true);
|
||||
throw new Error("network unavailable");
|
||||
} }, "0", next => { state = applyAgentPage(state, next); }), /network unavailable/);
|
||||
assert.equal(state.cursor, "10");
|
||||
await assert.rejects(syncAgentHistory({ messages: async () => page("10", [], true) }, state.cursor, next => { state = applyAgentPage(state, next); }), /did not advance/);
|
||||
assert.equal(state.cursor, "10");
|
||||
assert.throws(() => applyAgentPage(state, page("bad", [message(11)])), /cursor/);
|
||||
assert.equal(state.messages.length, 1);
|
||||
assert.throws(() => applyAgentPage(state, { ...page("11"), conversationId: "different" }), /conversation changed/);
|
||||
assert.equal(advanceAgentCursor("9007199254740993", "9007199254740992"), "9007199254740993");
|
||||
assert.throws(() => normalizeAgentCursor("-1"), /cursor/);
|
||||
});
|
||||
|
||||
test("read acknowledgments use only the rendered cursor and failed persistence retains unread replies", async () => {
|
||||
let state = applyAgentPage(createAgentRoomState(), page("5", [message(5)]));
|
||||
const renderedCursor = state.cursor;
|
||||
state = applyAgentPage(state, page("6", [message(6)]));
|
||||
const saved = [];
|
||||
let fail = true;
|
||||
const writer = createAgentReadWriter({
|
||||
current: () => state.readCursor,
|
||||
save: async cursor => { saved.push(cursor); if (fail) throw new Error("database offline"); return { cursor, updatedAt: null }; },
|
||||
apply: read => { state = applyAgentReadState(state, read); },
|
||||
});
|
||||
await assert.rejects(writer.request(renderedCursor), /database offline/);
|
||||
assert.equal(state.readCursor, "0");
|
||||
assert.equal(agentUnreadCount(state), 2);
|
||||
fail = false;
|
||||
await writer.flush();
|
||||
assert.deepEqual(saved, ["5", "5"]);
|
||||
assert.equal(state.readCursor, "5");
|
||||
assert.equal(agentUnreadCount(state), 1, "message6 arrived after render and must remain unread");
|
||||
});
|
||||
|
||||
test("read acknowledgments coalesce concurrent seen snapshots and reject unacknowledged cursors", async () => {
|
||||
let cursor = "0";
|
||||
let release;
|
||||
const gate = new Promise(resolve => { release = resolve; });
|
||||
const saves = [];
|
||||
const writer = createAgentReadWriter({ current: () => cursor, save: async value => {
|
||||
saves.push(value); if (value === "3") await gate; return { cursor: value, updatedAt: null };
|
||||
}, apply: read => { cursor = read.cursor; } });
|
||||
const first = writer.request("3"), second = writer.request("7");
|
||||
release(); await Promise.all([first, second]);
|
||||
assert.deepEqual(saves, ["3", "7"]);
|
||||
assert.equal(cursor, "7");
|
||||
const broken = createAgentReadWriter({ current: () => "0", save: async () => ({ cursor: "0", updatedAt: null }), apply: () => assert.fail("must not apply a failed acknowledgment") });
|
||||
await assert.rejects(broken.request("2"), /did not acknowledge/);
|
||||
});
|
||||
|
||||
test("failed persistence after acknowledgment retains the original receipt key across reload", async () => {
|
||||
let stored = [], writes = 0;
|
||||
const storage = { read: () => structuredClone(stored), write: rows => {
|
||||
if (++writes === 3) throw new Error("storage unavailable after acknowledgment");
|
||||
stored = structuredClone(rows);
|
||||
} };
|
||||
const requests = [];
|
||||
const open = () => createAgentOptimisticOutbox({
|
||||
storage, changed: () => {}, newId: () => "stable-request",
|
||||
submit: async (_content, requestId) => {
|
||||
requests.push(requestId);
|
||||
return { status: "placed", messageId: `inbound:sybil:${requestId}` };
|
||||
},
|
||||
});
|
||||
const original = open();
|
||||
await original.send("retry me");
|
||||
const accepted = original.display([])[0];
|
||||
assert.equal(accepted.metadata.status, "complete", "a storage error cannot undo server acceptance");
|
||||
assert.equal(stored[0].status, "sending", "the pre-admission receipt remains durable if its update fails");
|
||||
const restored = open();
|
||||
assert.deepEqual(requests, ["stable-request"], "reload never retries automatically");
|
||||
const uncertain = restored.display([])[0];
|
||||
assert.equal(uncertain.metadata.status, "unconfirmed");
|
||||
assert.equal(uncertain.id, accepted.id);
|
||||
assert.equal(uncertain.createdAt, accepted.createdAt);
|
||||
await restored.retry(uncertain.id);
|
||||
assert.deepEqual(requests, ["stable-request", "stable-request"], "manual retry cannot create a second admission");
|
||||
restored.reconcile({ messages: [message(1, "user", { id: accepted.id, content: "retry me" })] });
|
||||
assert.deepEqual(stored, [], "canonical echo finishes local cleanup");
|
||||
});
|
||||
|
||||
test("client uses Sybil auth and routes while websocket credentials remain in the first frame", async () => {
|
||||
const calls = [];
|
||||
let token = "first-session-token";
|
||||
const client = new AgentRoomClient({ baseUrl: "https://sybil.example/api/", token: () => token, fetch: async (url, init) => {
|
||||
calls.push({ url, init }); return new Response(JSON.stringify(init.method === "POST"
|
||||
? { id: 1, status: "queued", messageId: "inbound:sybil:request-1" } : { cursor: "0", updatedAt: null }), { status: 200 });
|
||||
} });
|
||||
await client.messages("0");
|
||||
token = "second-session-token";
|
||||
await client.markRead("42");
|
||||
await client.submit("hi", "request-1");
|
||||
assert.equal(calls[0].url, "https://sybil.example/api/v1/agent/messages?after=0&limit=200");
|
||||
assert.equal(calls[0].init.headers.get("Authorization"), "Bearer first-session-token");
|
||||
assert.equal(calls[1].init.headers.get("Authorization"), "Bearer second-session-token");
|
||||
assert.equal(calls[1].init.method, "PUT");
|
||||
assert.deepEqual(JSON.parse(calls[1].init.body), { cursor: "42" });
|
||||
assert.deepEqual(JSON.parse(calls[2].init.body), { content: "hi", requestId: "request-1" });
|
||||
const url = client.websocketUrl("https://sybil.example/");
|
||||
assert.equal(url, "wss://sybil.example/api/v1/agent/ws");
|
||||
assert.equal(new URL(url).search, "");
|
||||
assert.deepEqual(JSON.parse(agentAuthFrame(null, "42")), { type: "auth", token: null, after: "42" });
|
||||
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42" });
|
||||
});
|
||||
|
||||
test("malformed successful receipts keep optimistic messages retryable under the original request ID", async () => {
|
||||
const requests = [];
|
||||
let response = {};
|
||||
const client = new AgentRoomClient({ fetch: async (_url, init) => {
|
||||
requests.push(JSON.parse(init.body));
|
||||
return new Response(JSON.stringify(response), { status: 202 });
|
||||
} });
|
||||
const box = createAgentOptimisticOutbox({ submit: (content, requestId) => client.submit(content, requestId),
|
||||
storage: { read: () => [], write: () => {} }, changed: () => {}, newId: () => "retry-id",
|
||||
});
|
||||
await assert.rejects(box.send("Keep this request"), /invalid message receipt/);
|
||||
const row = box.display([])[0];
|
||||
assert.equal(row.metadata.status, "failed");
|
||||
assert.equal(box.snapshot()[0].seenOnServer, false);
|
||||
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 },
|
||||
{ ...valid, id: Number.MAX_SAFE_INTEGER + 1 }, { ...valid, status: "unknown" }, { ...valid, status: ["queued"] },
|
||||
{ ...valid, messageId: "inbound:sybil:other" }]) {
|
||||
response = malformed;
|
||||
await assert.rejects(box.retry(row.id), /invalid message receipt/);
|
||||
assert.equal(box.display([])[0].metadata.status, "failed");
|
||||
}
|
||||
response = valid;
|
||||
await box.retry(row.id);
|
||||
assert.equal(box.display([])[0].metadata.status, "queued");
|
||||
assert.ok(requests.every(request => request.requestId === "retry-id"), "uncertain responses cannot mint a duplicate admission");
|
||||
for (const status of ["placed", "done", "unanswered"]) {
|
||||
response = { ...valid, status };
|
||||
assert.equal((await client.submit("Keep this request", "retry-id")).status, status);
|
||||
}
|
||||
});
|
||||
|
||||
test("protocol ignores unknown frames and rejects malformed message cursors", () => {
|
||||
assert.equal(parseAgentRoomEvent('{"type":"future-frame"}'), null);
|
||||
assert.deepEqual(parseAgentRoomEvent('{"type":"pong"}'), { type: "pong" });
|
||||
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "messages", ...page("oops") })), /cursor/);
|
||||
const state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: { ...activity(), state: "thinking", isThinking: true } });
|
||||
assert.equal(state.activity.isThinking, true);
|
||||
assert.deepEqual(state.messages, [], "a thinking indicator never becomes a chat message");
|
||||
});
|
||||
@@ -8,6 +8,14 @@ const workspaceItems = [
|
||||
{ type: "search", id: "selected-search" },
|
||||
];
|
||||
|
||||
test("persistent agent selection survives history refreshes and empty collections", () => {
|
||||
const agent = { kind: "agent", id: "root" };
|
||||
assert.deepEqual(resolveSidebarSelectionAfterRefresh(agent, workspaceItems, { selectFallback: true }), agent);
|
||||
assert.deepEqual(resolveSidebarSelectionAfterRefresh(agent, []), agent);
|
||||
assert.deepEqual(resolveSidebarSelectionAfterRefresh(null, workspaceItems, { initialSelection: agent, selectFallback: true }), agent);
|
||||
assert.deepEqual(resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems, { initialSelection: agent }), { kind: "chat", id: "selected-chat" });
|
||||
});
|
||||
|
||||
test("a collection refresh preserves the current thread selection", () => {
|
||||
assert.deepEqual(
|
||||
resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems),
|
||||
|
||||
Reference in New Issue
Block a user