From 17f615e51ed140a1a73930cde8e123c650056946 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 14:51:42 +0800 Subject: [PATCH 01/12] refactor: extract connector classes from inline object literals Each connector is now a proper class that `implements Connector`: - McpAskConnector: no-op pull-based connector - TelegramConnector: photo + chunked text delivery via grammY Bot API - WebConnector: SSE broadcast + session persistence with sendStream Plugins instantiate their connector class instead of building anonymous closures. splitMessage/MAX_MESSAGE_LENGTH moved to telegram-connector.ts and re-exported for the plugin's own sendReply methods. Co-Authored-By: Claude Opus 4.6 --- src/connectors/mcp-ask/index.ts | 1 + src/connectors/mcp-ask/mcp-ask-connector.ts | 21 ++++ src/connectors/mcp-ask/mcp-ask-plugin.ts | 11 +- src/connectors/telegram/index.ts | 1 + src/connectors/telegram/telegram-connector.ts | 85 +++++++++++++ src/connectors/telegram/telegram-plugin.ts | 67 +---------- src/connectors/web/index.ts | 1 + src/connectors/web/web-connector.ts | 112 ++++++++++++++++++ src/connectors/web/web-plugin.ts | 100 +--------------- 9 files changed, 228 insertions(+), 171 deletions(-) create mode 100644 src/connectors/mcp-ask/mcp-ask-connector.ts create mode 100644 src/connectors/telegram/telegram-connector.ts create mode 100644 src/connectors/web/web-connector.ts diff --git a/src/connectors/mcp-ask/index.ts b/src/connectors/mcp-ask/index.ts index 080c8e0c..c2ba8cdf 100644 --- a/src/connectors/mcp-ask/index.ts +++ b/src/connectors/mcp-ask/index.ts @@ -1,2 +1,3 @@ export { McpAskPlugin } from './mcp-ask-plugin.js' export type { McpAskConfig } from './mcp-ask-plugin.js' +export { McpAskConnector } from './mcp-ask-connector.js' diff --git a/src/connectors/mcp-ask/mcp-ask-connector.ts b/src/connectors/mcp-ask/mcp-ask-connector.ts new file mode 100644 index 00000000..89a9d58f --- /dev/null +++ b/src/connectors/mcp-ask/mcp-ask-connector.ts @@ -0,0 +1,21 @@ +/** + * MCP Ask no-op connector. + * + * MCP is a pull-based protocol — external clients call tools to interact + * with Alice. There is no push channel, so send() always returns + * delivered: false. Registered with ConnectorCenter so the system knows + * this channel exists but cannot receive proactive notifications. + */ + +import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from '../types.js' + +export class McpAskConnector implements Connector { + readonly channel = 'mcp-ask' + readonly to = 'default' + readonly capabilities: ConnectorCapabilities = { push: false, media: false } + + async send(_payload: SendPayload): Promise { + // MCP is pull-based; outbound send is a no-op. + return { delivered: false } + } +} diff --git a/src/connectors/mcp-ask/mcp-ask-plugin.ts b/src/connectors/mcp-ask/mcp-ask-plugin.ts index 41a8a8b4..e972a434 100644 --- a/src/connectors/mcp-ask/mcp-ask-plugin.ts +++ b/src/connectors/mcp-ask/mcp-ask-plugin.ts @@ -20,6 +20,7 @@ import { glob } from 'node:fs/promises' import { join, basename } from 'node:path' import type { Plugin, EngineContext } from '../../core/types.js' import { SessionStore, toTextHistory } from '../../core/session.js' +import { McpAskConnector } from './mcp-ask-connector.js' export interface McpAskConfig { port: number @@ -126,15 +127,7 @@ export class McpAskPlugin implements Plugin { }) // Register as connector for outbound delivery (heartbeat/cron) - this.unregisterConnector = ctx.connectorCenter.register({ - channel: 'mcp-ask', - to: 'default', - capabilities: { push: false, media: false }, - send: async () => { - // MCP is pull-based; outbound send is a no-op. - return { delivered: false } - }, - }) + this.unregisterConnector = ctx.connectorCenter.register(new McpAskConnector()) this.server = serve({ fetch: app.fetch, port: this.config.port }, (info) => { console.log(`mcp-ask connector listening on http://localhost:${info.port}/mcp`) diff --git a/src/connectors/telegram/index.ts b/src/connectors/telegram/index.ts index 1e22a438..0640dc10 100644 --- a/src/connectors/telegram/index.ts +++ b/src/connectors/telegram/index.ts @@ -1,2 +1,3 @@ export { TelegramPlugin } from './telegram-plugin.js' +export { TelegramConnector } from './telegram-connector.js' export type { TelegramConfig, ParsedMessage, MediaRef } from './types.js' diff --git a/src/connectors/telegram/telegram-connector.ts b/src/connectors/telegram/telegram-connector.ts new file mode 100644 index 00000000..90ca452d --- /dev/null +++ b/src/connectors/telegram/telegram-connector.ts @@ -0,0 +1,85 @@ +/** + * Telegram outbound connector. + * + * Delivers messages and media to a specific Telegram chat via the grammY + * Bot API. Handles photo attachments (read from disk, sent via sendPhoto) + * and automatic text chunking for messages exceeding Telegram's 4096-char limit. + * + * Does not support streaming (no sendStream) — ConnectorCenter falls back + * to draining the stream and calling send() with the completed result. + */ + +import { readFile } from 'node:fs/promises' +import { Bot, InputFile } from 'grammy' +import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from '../types.js' + +export const MAX_MESSAGE_LENGTH = 4096 + +export class TelegramConnector implements Connector { + readonly channel = 'telegram' + readonly to: string + readonly capabilities: ConnectorCapabilities = { push: true, media: true } + + constructor( + private readonly bot: Bot, + private readonly chatId: number, + ) { + this.to = String(chatId) + } + + async send(payload: SendPayload): Promise { + // Send media first (photos) + if (payload.media && payload.media.length > 0) { + for (const attachment of payload.media) { + try { + const buf = await readFile(attachment.path) + await this.bot.api.sendPhoto(this.chatId, new InputFile(buf, 'screenshot.jpg')) + } catch (err) { + console.error('telegram: failed to send photo:', err) + } + } + } + + // Send text with chunking + if (payload.text) { + const chunks = splitMessage(payload.text, MAX_MESSAGE_LENGTH) + for (const chunk of chunks) { + await this.bot.api.sendMessage(this.chatId, chunk) + } + } + + return { delivered: true } + } +} + +// ==================== Helpers ==================== + +export function splitMessage(text: string, maxLength: number): string[] { + if (text.length <= maxLength) return [text] + + const chunks: string[] = [] + let remaining = text + + while (remaining.length > 0) { + if (remaining.length <= maxLength) { + chunks.push(remaining) + break + } + + // Try to split at a newline + let splitAt = remaining.lastIndexOf('\n', maxLength) + if (splitAt === -1 || splitAt < maxLength / 2) { + // Fall back to splitting at a space + splitAt = remaining.lastIndexOf(' ', maxLength) + } + if (splitAt === -1 || splitAt < maxLength / 2) { + // Hard split + splitAt = maxLength + } + + chunks.push(remaining.slice(0, splitAt)) + remaining = remaining.slice(splitAt).trimStart() + } + + return chunks +} diff --git a/src/connectors/telegram/telegram-plugin.ts b/src/connectors/telegram/telegram-plugin.ts index fdb6b757..d7f7b337 100644 --- a/src/connectors/telegram/telegram-plugin.ts +++ b/src/connectors/telegram/telegram-plugin.ts @@ -12,9 +12,7 @@ import { SessionStore } from '../../core/session' import { forceCompact } from '../../core/compaction' import { readAIBackend, writeAIBackend, type AIBackend } from '../../core/config' import type { ConnectorCenter } from '../../core/connector-center.js' -import type { Connector } from '../types.js' - -const MAX_MESSAGE_LENGTH = 4096 +import { TelegramConnector, splitMessage, MAX_MESSAGE_LENGTH } from './telegram-connector.js' const BACKEND_LABELS: Record = { 'claude-code': 'Claude Code', @@ -177,7 +175,7 @@ export class TelegramPlugin implements Plugin { // ── Register connector for outbound delivery (heartbeat / cron responses) ── if (this.config.allowedChatIds.length > 0) { const deliveryChatId = this.config.allowedChatIds[0] - this.unregisterConnector = this.connectorCenter!.register(this.createConnector(bot, deliveryChatId)) + this.unregisterConnector = this.connectorCenter!.register(new TelegramConnector(bot, deliveryChatId)) } // ── Start polling ── @@ -196,37 +194,6 @@ export class TelegramPlugin implements Plugin { this.unregisterConnector?.() } - private createConnector(bot: Bot, chatId: number): Connector { - return { - channel: 'telegram', - to: String(chatId), - capabilities: { push: true, media: true }, - send: async (payload) => { - // Send media first (photos) - if (payload.media && payload.media.length > 0) { - for (const attachment of payload.media) { - try { - const buf = await readFile(attachment.path) - await bot.api.sendPhoto(chatId, new InputFile(buf, 'screenshot.jpg')) - } catch (err) { - console.error('telegram: failed to send photo:', err) - } - } - } - - // Send text with chunking - if (payload.text) { - const chunks = splitMessage(payload.text, MAX_MESSAGE_LENGTH) - for (const chunk of chunks) { - await bot.api.sendMessage(chatId, chunk) - } - } - - return { delivered: true } - }, - } - } - private async getSession(userId: number): Promise { let session = this.sessions.get(userId) if (!session) { @@ -436,33 +403,3 @@ export class TelegramPlugin implements Plugin { } } } - -function splitMessage(text: string, maxLength: number): string[] { - if (text.length <= maxLength) return [text] - - const chunks: string[] = [] - let remaining = text - - while (remaining.length > 0) { - if (remaining.length <= maxLength) { - chunks.push(remaining) - break - } - - // Try to split at a newline - let splitAt = remaining.lastIndexOf('\n', maxLength) - if (splitAt === -1 || splitAt < maxLength / 2) { - // Fall back to splitting at a space - splitAt = remaining.lastIndexOf(' ', maxLength) - } - if (splitAt === -1 || splitAt < maxLength / 2) { - // Hard split - splitAt = maxLength - } - - chunks.push(remaining.slice(0, splitAt)) - remaining = remaining.slice(splitAt).trimStart() - } - - return chunks -} diff --git a/src/connectors/web/index.ts b/src/connectors/web/index.ts index e94617ef..65d40285 100644 --- a/src/connectors/web/index.ts +++ b/src/connectors/web/index.ts @@ -1,2 +1,3 @@ export { WebPlugin } from './web-plugin.js' +export { WebConnector } from './web-connector.js' export type { WebConfig } from './web-plugin.js' diff --git a/src/connectors/web/web-connector.ts b/src/connectors/web/web-connector.ts new file mode 100644 index 00000000..1d6d702a --- /dev/null +++ b/src/connectors/web/web-connector.ts @@ -0,0 +1,112 @@ +/** + * Web UI outbound connector. + * + * Delivers messages and streaming AI responses to connected web clients via + * Server-Sent Events (SSE). Persists media attachments to the content-addressable + * media store and records all outbound messages in the session JSONL for history. + * + * Supports both send() for completed messages and sendStream() for real-time + * streaming of ProviderEvents (tool_use, tool_result, text) to the browser. + */ + +import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from '../types.js' +import type { StreamableResult } from '../../core/ai-provider.js' +import type { SSEClient } from './routes/chat.js' +import { SessionStore, type ContentBlock } from '../../core/session.js' +import { persistMedia } from '../../core/media-store.js' + +export class WebConnector implements Connector { + readonly channel = 'web' + readonly to = 'default' + readonly capabilities: ConnectorCapabilities = { push: true, media: true } + + constructor( + private readonly sseByChannel: Map>, + private readonly session: SessionStore, + ) {} + + async send(payload: SendPayload): Promise { + // Persist media to data/media/ with 3-word names + const media: Array<{ type: 'image'; url: string }> = [] + for (const m of payload.media ?? []) { + const name = await persistMedia(m.path) + media.push({ type: 'image', url: `/api/media/${name}` }) + } + + const data = JSON.stringify({ + type: 'message', + kind: payload.kind, + text: payload.text, + media: media.length > 0 ? media : undefined, + source: payload.source, + }) + + // Only broadcast to default channel SSE clients (heartbeat/cron stay in main channel) + const defaultClients = this.sseByChannel.get('default') ?? new Map() + for (const client of defaultClients.values()) { + try { client.send(data) } catch { /* client disconnected */ } + } + + // Persist to session so history survives page refresh (text + image blocks) + const blocks: ContentBlock[] = [ + { type: 'text', text: payload.text }, + ...media.map((m) => ({ type: 'image' as const, url: m.url })), + ] + await this.session.appendAssistant(blocks, 'notification', { + kind: payload.kind, + source: payload.source, + }) + + return { delivered: defaultClients.size > 0 } + } + + async sendStream( + stream: StreamableResult, + meta?: Pick, + ): Promise { + const defaultClients = this.sseByChannel.get('default') ?? new Map() + + // Push streaming events to SSE clients as they arrive + for await (const event of stream) { + if (event.type === 'done') continue + const data = JSON.stringify({ type: 'stream', event }) + for (const client of defaultClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + } + + // Get completed result (resolves immediately — drain already finished) + const result = await stream + + // Persist media + const media: Array<{ type: 'image'; url: string }> = [] + for (const m of result.media) { + const name = await persistMedia(m.path) + media.push({ type: 'image', url: `/api/media/${name}` }) + } + + // Push final message to SSE (same format as send()) + const data = JSON.stringify({ + type: 'message', + kind: meta?.kind ?? 'notification', + text: result.text, + media: media.length > 0 ? media : undefined, + source: meta?.source, + }) + for (const client of defaultClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + + // Persist to session (push notifications appear in web chat history) + const blocks: ContentBlock[] = [ + { type: 'text', text: result.text }, + ...media.map((m) => ({ type: 'image' as const, url: m.url })), + ] + await this.session.appendAssistant(blocks, 'notification', { + kind: meta?.kind ?? 'notification', + source: meta?.source, + }) + + return { delivered: defaultClients.size > 0 } + } +} diff --git a/src/connectors/web/web-plugin.ts b/src/connectors/web/web-plugin.ts index 11b55435..9458020c 100644 --- a/src/connectors/web/web-plugin.ts +++ b/src/connectors/web/web-plugin.ts @@ -4,10 +4,8 @@ import { serve } from '@hono/node-server' import { serveStatic } from '@hono/node-server/serve-static' import { resolve } from 'node:path' import type { Plugin, EngineContext } from '../../core/types.js' -import { SessionStore, type ContentBlock } from '../../core/session.js' -import type { Connector, SendPayload } from '../types.js' -import type { StreamableResult } from '../../core/ai-provider.js' -import { persistMedia } from '../../core/media-store.js' +import { SessionStore } from '../../core/session.js' +import { WebConnector } from './web-connector.js' import { readWebSubchannels } from '../../core/config.js' import { createChatRoutes, createMediaRoutes, type SSEClient } from './routes/chat.js' import { createChannelsRoutes } from './routes/channels.js' @@ -92,7 +90,7 @@ export class WebPlugin implements Plugin { // ==================== Connector registration ==================== // The web connector only targets the main 'default' channel (heartbeat/cron notifications). this.unregisterConnector = ctx.connectorCenter.register( - this.createConnector(this.sseByChannel, defaultSession), + new WebConnector(this.sseByChannel, defaultSession), ) // ==================== Start server ==================== @@ -106,96 +104,4 @@ export class WebPlugin implements Plugin { this.unregisterConnector?.() this.server?.close() } - - private createConnector( - sseByChannel: Map>, - session: SessionStore, - ): Connector { - return { - channel: 'web', - to: 'default', - capabilities: { push: true, media: true }, - send: async (payload) => { - // Persist media to data/media/ with 3-word names - const media: Array<{ type: 'image'; url: string }> = [] - for (const m of payload.media ?? []) { - const name = await persistMedia(m.path) - media.push({ type: 'image', url: `/api/media/${name}` }) - } - - const data = JSON.stringify({ - type: 'message', - kind: payload.kind, - text: payload.text, - media: media.length > 0 ? media : undefined, - source: payload.source, - }) - - // Only broadcast to default channel SSE clients (heartbeat/cron stay in main channel) - const defaultClients = sseByChannel.get('default') ?? new Map() - for (const client of defaultClients.values()) { - try { client.send(data) } catch { /* client disconnected */ } - } - - // Persist to session so history survives page refresh (text + image blocks) - const blocks: ContentBlock[] = [ - { type: 'text', text: payload.text }, - ...media.map((m) => ({ type: 'image' as const, url: m.url })), - ] - await session.appendAssistant(blocks, 'notification', { - kind: payload.kind, - source: payload.source, - }) - - return { delivered: defaultClients.size > 0 } - }, - - sendStream: async (stream: StreamableResult, meta?: Pick) => { - const defaultClients = sseByChannel.get('default') ?? new Map() - - // Push streaming events to SSE clients as they arrive - for await (const event of stream) { - if (event.type === 'done') continue - const data = JSON.stringify({ type: 'stream', event }) - for (const client of defaultClients.values()) { - try { client.send(data) } catch { /* disconnected */ } - } - } - - // Get completed result (resolves immediately — drain already finished) - const result = await stream - - // Persist media - const media: Array<{ type: 'image'; url: string }> = [] - for (const m of result.media) { - const name = await persistMedia(m.path) - media.push({ type: 'image', url: `/api/media/${name}` }) - } - - // Push final message to SSE (same format as send()) - const data = JSON.stringify({ - type: 'message', - kind: meta?.kind ?? 'notification', - text: result.text, - media: media.length > 0 ? media : undefined, - source: meta?.source, - }) - for (const client of defaultClients.values()) { - try { client.send(data) } catch { /* disconnected */ } - } - - // Persist to session (push notifications appear in web chat history) - const blocks: ContentBlock[] = [ - { type: 'text', text: result.text }, - ...media.map((m) => ({ type: 'image' as const, url: m.url })), - ] - await session.appendAssistant(blocks, 'notification', { - kind: meta?.kind ?? 'notification', - source: meta?.source, - }) - - return { delivered: defaultClients.size > 0 } - }, - } - } } From 180b2a5c2276ae5c6c15feed27b0069fc77b51e8 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 15:26:57 +0800 Subject: [PATCH 02/12] fix: add pnpm workspace so opentypebb deps are installed automatically Without pnpm-workspace.yaml, `pnpm install` at the root did not install dependencies for the linked opentypebb package (e.g. yahoo-finance2), causing ERR_MODULE_NOT_FOUND on fresh clones. Closes #43 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- pnpm-lock.yaml | 492 +++++++++++++++++++++++++++++++++++++++++++- pnpm-workspace.yaml | 2 + 3 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/package.json b/package.json index f69f4e93..5fdfba57 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "grammy": "^1.40.0", "hono": "^4.12.5", "json5": "^2.2.3", - "@traderalice/opentypebb": "link:./packages/opentypebb", + "@traderalice/opentypebb": "workspace:*", "pino": "^10.3.1", "playwright-core": "1.58.2", "sharp": "^0.34.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b0de3db..5a899797 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,7 @@ importers: specifier: 0.34.48 version: 0.34.48 '@traderalice/opentypebb': - specifier: link:./packages/opentypebb + specifier: workspace:* version: link:packages/opentypebb ai: specifier: ^6.0.86 @@ -115,6 +115,40 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(tsx@4.21.0) + packages/opentypebb: + dependencies: + '@hono/node-server': + specifier: ^1.13.8 + version: 1.19.11(hono@4.12.7) + hono: + specifier: ^4.12.7 + version: 4.12.7 + undici: + specifier: ^7.22.0 + version: 7.22.0 + yahoo-finance2: + specifier: ^3.13.1 + version: 3.13.2 + zod: + specifier: ^3.24.2 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.13.4 + version: 22.19.15 + tsup: + specifier: ^8.4.0 + version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + tsx: + specifier: ^4.19.3 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.6 + version: 3.2.4(@types/node@22.19.15)(tsx@4.21.0) + packages: '@ai-sdk/anthropic@3.0.44': @@ -164,6 +198,12 @@ packages: '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@deno/shim-deno-test@0.5.0': + resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==} + + '@deno/shim-deno@0.18.2': + resolution: {integrity: sha512-oQ0CVmOio63wlhwQF75zA4ioolPvOwAoK0yuzcS5bDC1JUvH3y1GS8xPh8EOpcoDQRU4FTG8OQfxhpR+c6DrzA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -739,6 +779,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -764,9 +807,23 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.0.18': resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: @@ -778,18 +835,33 @@ packages: vite: optional: true + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} @@ -905,6 +977,10 @@ packages: resolution: {integrity: sha512-mLNwzq/GbSExA5QxVaIjud5AlhYxKY0q48dV4IHjBaUQNThbBzsGM1DdL60ofO/A4/xoRyBSjOy/YIsAFird7g==} engines: {node: '>=15.0.0'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -917,6 +993,10 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -978,6 +1058,10 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1031,6 +1115,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1133,6 +1221,9 @@ packages: picomatch: optional: true + fetch-mock-cache@2.3.1: + resolution: {integrity: sha512-hDk+Nbt0Y8Aq7KTEU6ASQAcpB34UjhkpD3QjzD6yvEKP4xVElAqXrjQ7maL+LYMGafx51Zq6qUfDM57PNu/qMw==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -1141,6 +1232,18 @@ packages: resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} engines: {node: '>=20'} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify-url@2.1.2: + resolution: {integrity: sha512-3rMbAr7vDNMOGsj1aMniQFl749QjgM+lMJ/77ZRSPTIgxvolZwoQbn8dXLs7xfd+hAdli+oTnSWZNkJJLWQFEQ==} + engines: {node: '>=8'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -1237,10 +1340,18 @@ packages: resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==} engines: {node: '>=16.9.0'} + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} + engines: {node: '>=16.9.0'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + humanize-url@2.1.1: + resolution: {integrity: sha512-V4nxsPGNE7mPjr1qDp471YfW8nhBiTRWrG/4usZlpvFU8I7gsV7Jvrrzv/snbLm5dWO3dr1ennu2YqnhTWFmYA==} + engines: {node: '>=8'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -1293,6 +1404,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -1300,6 +1415,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -1358,6 +1476,9 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1425,6 +1546,10 @@ packages: encoding: optional: true + normalize-url@4.5.1: + resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==} + engines: {node: '>=8'} + nuid@1.1.6: resolution: {integrity: sha512-Eb3CPCupYscP1/S1FQcO5nxtu6l/F3k0MQ69h7f5osnsemVk5pkc8/5AyalVT+NCfra9M71U8POqF6EZa6IHvg==} engines: {node: '>= 8.16.0'} @@ -1489,6 +1614,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1555,6 +1684,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1563,6 +1695,9 @@ packages: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -1593,6 +1728,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1718,6 +1856,13 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} @@ -1759,10 +1904,29 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1771,6 +1935,18 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tough-cookie-file-store@2.0.3: + resolution: {integrity: sha512-sMpZVcmFf6EYFHFFl+SYH4W1/OnXBYMGDsv2IlbQ2caHyFElW/UR/gpj/KYU1JwmP4dE9xqwv2+vWcmlXHojSw==} + engines: {node: '>=6'} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -1778,6 +1954,10 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -1842,6 +2022,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -1849,6 +2032,10 @@ packages: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -1856,6 +2043,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + urljoin@0.1.5: resolution: {integrity: sha512-OSGi+PS3zxk8XfQ+7buaupOdrW9P9p+V9rjxGzJaYEYDe/B2rv3WJCupq5LNERW4w4kWxsduUUrhCxZZiQ2udw==} @@ -1866,6 +2056,11 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1906,6 +2101,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1951,6 +2174,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1987,6 +2215,11 @@ packages: utf-8-validate: optional: true + yahoo-finance2@3.13.2: + resolution: {integrity: sha512-aAOJEjuLClfDxVPRKxjcwFoyzMr8BE/svgUqr5IjnQR+kppYbKO92Wl3SbAGz5DRghy6FiUfqi5FBDSBA/e2jg==} + engines: {node: '>=20.0.0'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1996,6 +2229,9 @@ packages: peerDependencies: zod: ^3.25 || ^4 + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2072,6 +2308,13 @@ snapshots: '@borewit/text-codec@0.2.1': {} + '@deno/shim-deno-test@0.5.0': {} + + '@deno/shim-deno@0.18.2': + dependencies: + '@deno/shim-deno-test': 0.5.0 + which: 4.0.0 + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -2191,6 +2434,10 @@ snapshots: dependencies: hono: 4.12.5 + '@hono/node-server@1.19.11(hono@4.12.7)': + dependencies: + hono: 4.12.7 + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -2472,6 +2719,10 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -2497,6 +2748,14 @@ snapshots: '@vercel/oidc@3.1.0': {} + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -2506,6 +2765,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.15)(tsx@4.21.0) + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.18 @@ -2514,23 +2781,49 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(tsx@4.21.0) + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.0.18': dependencies: '@vitest/utils': 4.0.18 pathe: 2.0.3 + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.0.18': {} + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 @@ -2659,6 +2952,14 @@ snapshots: - bufferutil - utf-8-validate + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chalk@4.1.2: @@ -2668,6 +2969,8 @@ snapshots: chalk@5.6.2: {} + check-error@2.1.3: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2711,6 +3014,8 @@ snapshots: decimal.js@10.6.0: {} + deep-eql@5.0.2: {} + deep-is@0.1.4: {} depd@2.0.0: {} @@ -2774,6 +3079,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} eslint-scope@7.2.2: @@ -2918,6 +3225,13 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-mock-cache@2.3.1: + dependencies: + debug: 4.4.3 + filenamify-url: 2.1.2 + transitivePeerDependencies: + - supports-color + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -2931,6 +3245,19 @@ snapshots: transitivePeerDependencies: - supports-color + filename-reserved-regex@2.0.0: {} + + filenamify-url@2.1.2: + dependencies: + filenamify: 4.3.0 + humanize-url: 2.1.1 + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -3037,6 +3364,8 @@ snapshots: hono@4.12.5: {} + hono@4.12.7: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -3045,6 +3374,10 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + humanize-url@2.1.1: + dependencies: + normalize-url: 4.5.1 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -3083,10 +3416,14 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.5: {} + jose@6.1.3: {} joycon@3.1.1: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -3130,6 +3467,8 @@ snapshots: lodash@4.17.23: {} + loupe@3.2.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3189,6 +3528,8 @@ snapshots: dependencies: whatwg-url: 5.0.0 + normalize-url@4.5.1: {} + nuid@1.1.6: {} object-assign@4.1.1: {} @@ -3240,6 +3581,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -3298,12 +3641,18 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + punycode@2.3.1: {} qs@6.15.0: dependencies: side-channel: 1.1.0 + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -3329,6 +3678,8 @@ snapshots: require-from-string@2.0.2: {} + requires-port@1.0.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -3514,6 +3865,14 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + strtok3@10.3.4: dependencies: '@tokenizer/token': 0.3.0 @@ -3557,8 +3916,20 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + toidentifier@1.0.1: {} token-types@6.1.2: @@ -3567,10 +3938,29 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie-file-store@2.0.3: + dependencies: + tough-cookie: 4.1.4 + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tr46@0.0.3: {} tree-kill@1.2.2: {} + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + ts-interface-checker@0.1.13: {} ts-nkeys@1.0.16: @@ -3637,16 +4027,25 @@ snapshots: uint8array-extras@1.5.0: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} undici@7.22.0: {} + universalify@0.2.0: {} + unpipe@1.0.0: {} uri-js@4.4.1: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + urljoin@0.1.5: dependencies: extend: 2.0.2 @@ -3655,6 +4054,40 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.4(@types/node@22.19.15)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.15)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@22.19.15)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + tsx: 4.21.0 + vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0): dependencies: esbuild: 0.27.3 @@ -3668,6 +4101,47 @@ snapshots: fsevents: 2.3.3 tsx: 4.21.0 + vitest@3.2.4(@types/node@22.19.15)(tsx@4.21.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(tsx@4.21.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.15)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@22.19.15)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.18 @@ -3717,6 +4191,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@4.0.0: + dependencies: + isexe: 3.1.5 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -3730,10 +4208,22 @@ snapshots: ws@8.19.0: {} + yahoo-finance2@3.13.2: + dependencies: + '@deno/shim-deno': 0.18.2 + fetch-mock-cache: 2.3.1 + json-schema: 0.4.0 + tough-cookie: 5.1.2 + tough-cookie-file-store: 2.0.3 + transitivePeerDependencies: + - supports-color + yocto-queue@0.1.0: {} zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 + zod@3.25.76: {} + zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* From fc9fd0bcafbfe8557366a018259323d8fb1f5490 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 15:41:04 +0800 Subject: [PATCH 03/12] test: add web streaming integration tests (chat pipeline + SSE transport) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test suites verifying the streaming path from provider to SSE client: - chat-streaming: 7 tests for the in-memory pipeline (FakeProvider → AgentCenter → StreamableResult → SSE client) - sse-transport: 3 tests with a real Hono HTTP server verifying writeSSE delivers events to raw HTTP clients in real-time Co-Authored-By: Claude Opus 4.6 --- .../web/__tests__/chat-streaming.spec.ts | 265 +++++++++++++++ .../web/__tests__/sse-transport.spec.ts | 306 ++++++++++++++++++ 2 files changed, 571 insertions(+) create mode 100644 src/connectors/web/__tests__/chat-streaming.spec.ts create mode 100644 src/connectors/web/__tests__/sse-transport.spec.ts diff --git a/src/connectors/web/__tests__/chat-streaming.spec.ts b/src/connectors/web/__tests__/chat-streaming.spec.ts new file mode 100644 index 00000000..125f019e --- /dev/null +++ b/src/connectors/web/__tests__/chat-streaming.spec.ts @@ -0,0 +1,265 @@ +/** + * Web UI streaming tests. + * + * Simulates the chat.ts POST handler flow: AgentCenter produces a + * StreamableResult, which is iterated and forwarded to SSE clients. + * Verifies that tool_use, tool_result, and intermediate text events + * all reach the SSE clients in order. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createChannel } from '../../../core/async-channel.js' +import { + StreamableResult, + type ProviderEvent, +} from '../../../core/ai-provider.js' +import { + FakeProvider, + MemorySessionStore, + makeAgentCenter, + toolUseEvent, + toolResultEvent, + textEvent, + doneEvent, +} from '../../../core/__tests__/pipeline/helpers.js' +import type { SSEClient } from '../routes/chat.js' + +// ==================== Module Mocks ==================== + +vi.mock('../../../core/compaction.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + compactIfNeeded: vi.fn().mockResolvedValue({ compacted: false, method: 'none' }), + } +}) + +vi.mock('../../../core/media-store.js', () => ({ + persistMedia: vi.fn().mockResolvedValue('2026-03-13/ace-aim-air.png'), + resolveMediaPath: vi.fn((name: string) => `/mock/media/${name}`), +})) + +vi.mock('../../../ai-providers/log-tool-call.js', () => ({ + logToolCall: vi.fn(), +})) + +// ==================== Helpers ==================== + +/** Simulate the chat.ts POST handler streaming loop. */ +async function simulateChatPost( + stream: StreamableResult, + clients: Map, +) { + for await (const event of stream) { + if (event.type === 'done') continue + const data = JSON.stringify({ type: 'stream', event }) + for (const client of clients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + } + return await stream +} + +/** Create a capturing SSE client that records all sent data. */ +function makeCapturingClient(): { client: SSEClient; sent: string[] } { + const sent: string[] = [] + return { + sent, + client: { + id: 'test-client', + send: (data: string) => { sent.push(data) }, + }, + } +} + +// ==================== Tests ==================== + +describe('Web UI chat streaming', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should push tool_use, tool_result, and text events to SSE client', async () => { + const provider = new FakeProvider([ + toolUseEvent('t1', 'getQuote', { symbol: 'AAPL' }), + toolResultEvent('t1', '{"price": 185}'), + textEvent('AAPL is at $185'), + doneEvent('AAPL is at $185'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + const stream = ac.askWithSession('check AAPL', session) + + const { client, sent } = makeCapturingClient() + const clients = new Map([['c1', client]]) + + const result = await simulateChatPost(stream, clients) + + // All 3 streaming events should reach SSE (done is skipped) + expect(sent).toHaveLength(3) + + const parsed = sent.map(s => JSON.parse(s)) + expect(parsed[0]).toEqual({ type: 'stream', event: { type: 'tool_use', id: 't1', name: 'getQuote', input: { symbol: 'AAPL' } } }) + expect(parsed[1]).toEqual({ type: 'stream', event: { type: 'tool_result', tool_use_id: 't1', content: '{"price": 185}' } }) + expect(parsed[2]).toEqual({ type: 'stream', event: { type: 'text', text: 'AAPL is at $185' } }) + + expect(result.text).toBe('AAPL is at $185') + }) + + it('should handle multiple tool loops with intermediate text', async () => { + const provider = new FakeProvider([ + textEvent('Let me check...'), + toolUseEvent('t1', 'getPortfolio', {}), + toolResultEvent('t1', '[{pos: AAPL}]'), + textEvent('Now calculating...'), + toolUseEvent('t2', 'calculateIndicator', { formula: 'RSI' }), + toolResultEvent('t2', '45.3'), + textEvent('Based on analysis, RSI is 45.3'), + doneEvent('Based on analysis, RSI is 45.3'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + const stream = ac.askWithSession('analyze portfolio', session) + + const { client, sent } = makeCapturingClient() + const clients = new Map([['c1', client]]) + + await simulateChatPost(stream, clients) + + // 7 events: text, tool_use, tool_result, text, tool_use, tool_result, text + expect(sent).toHaveLength(7) + + const types = sent.map(s => JSON.parse(s).event.type) + expect(types).toEqual([ + 'text', 'tool_use', 'tool_result', + 'text', 'tool_use', 'tool_result', + 'text', + ]) + }) + + it('should push events to multiple SSE clients', async () => { + const provider = new FakeProvider([ + toolUseEvent('t1', 'Read', { path: '/tmp' }), + toolResultEvent('t1', 'contents'), + textEvent('Done'), + doneEvent('Done'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + const stream = ac.askWithSession('read file', session) + + const c1 = makeCapturingClient() + const c2 = makeCapturingClient() + const clients = new Map([['c1', c1.client], ['c2', c2.client]]) + + await simulateChatPost(stream, clients) + + expect(c1.sent).toHaveLength(3) + expect(c2.sent).toHaveLength(3) + expect(c1.sent).toEqual(c2.sent) + }) + + it('should deliver events with no SSE clients without error', async () => { + const provider = new FakeProvider([ + toolUseEvent('t1', 'Read', {}), + toolResultEvent('t1', 'ok'), + doneEvent('ok'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + const stream = ac.askWithSession('test', session) + + const emptyClients = new Map() + const result = await simulateChatPost(stream, emptyClients) + + expect(result.text).toBe('ok') + }) + + it('should work with AsyncChannel-based provider (simulates Claude Code CLI)', async () => { + // Simulates the ClaudeCodeProvider pattern: callbacks push to channel + const channel = createChannel() + + // Simulate async CLI output arriving over time + setTimeout(() => { + channel.push({ type: 'tool_use', id: 't1', name: 'Glob', input: { pattern: '*.ts' } }) + }, 5) + setTimeout(() => { + channel.push({ type: 'tool_result', tool_use_id: 't1', content: 'file1.ts\nfile2.ts' }) + }, 10) + setTimeout(() => { + channel.push({ type: 'text', text: 'Found 2 files' }) + }, 15) + setTimeout(() => { + channel.push({ type: 'done', result: { text: 'Found 2 files', media: [] } }) + channel.close() + }, 20) + + const sr = new StreamableResult(channel) + + const { client, sent } = makeCapturingClient() + const clients = new Map([['c1', client]]) + + await simulateChatPost(sr, clients) + + expect(sent).toHaveLength(3) + + const types = sent.map(s => JSON.parse(s).event.type) + expect(types).toEqual(['tool_use', 'tool_result', 'text']) + }) + + it('should work when all events arrive synchronously (burst mode)', async () => { + // Simulates a fast CLI where all events are already in the buffer + const channel = createChannel() + + // Push all events synchronously (same tick) + channel.push({ type: 'tool_use', id: 't1', name: 'Read', input: {} }) + channel.push({ type: 'tool_result', tool_use_id: 't1', content: 'data' }) + channel.push({ type: 'text', text: 'result' }) + channel.push({ type: 'done', result: { text: 'result', media: [] } }) + channel.close() + + const sr = new StreamableResult(channel) + + const { client, sent } = makeCapturingClient() + const clients = new Map([['c1', client]]) + + await simulateChatPost(sr, clients) + + expect(sent).toHaveLength(3) + + const types = sent.map(s => JSON.parse(s).event.type) + expect(types).toEqual(['tool_use', 'tool_result', 'text']) + }) + + it('should work with AgentCenter pipeline (full integration)', async () => { + // Full integration: FakeProvider → AgentCenter._generate() → StreamableResult → SSE + const provider = new FakeProvider([ + toolUseEvent('t1', 'getAccount', {}), + toolResultEvent('t1', '{"cash": 100000}'), + toolUseEvent('t2', 'getQuote', { aliceId: 'alpaca-AAPL' }), + toolResultEvent('t2', '{"last": 255.71}'), + textEvent('Account has $100k, AAPL at $255.71'), + doneEvent('Account has $100k, AAPL at $255.71'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + + const stream = ac.askWithSession('show account', session) + + const { client, sent } = makeCapturingClient() + const clients = new Map([['c1', client]]) + + const result = await simulateChatPost(stream, clients) + + // 5 events: tool_use, tool_result, tool_use, tool_result, text + expect(sent).toHaveLength(5) + + const types = sent.map(s => JSON.parse(s).event.type) + expect(types).toEqual([ + 'tool_use', 'tool_result', + 'tool_use', 'tool_result', + 'text', + ]) + + expect(result.text).toBe('Account has $100k, AAPL at $255.71') + }) +}) diff --git a/src/connectors/web/__tests__/sse-transport.spec.ts b/src/connectors/web/__tests__/sse-transport.spec.ts new file mode 100644 index 00000000..67d56ac5 --- /dev/null +++ b/src/connectors/web/__tests__/sse-transport.spec.ts @@ -0,0 +1,306 @@ +/** + * SSE transport integration test. + * + * Stands up a real Hono HTTP server with the same SSE pattern as chat.ts, + * then uses a raw HTTP client to verify events arrive in real-time. + * + * This tests the gap that unit tests don't cover: the actual HTTP transport + * from server-side writeSSE() through @hono/node-server to the client. + */ +import { describe, it, expect, afterEach } from 'vitest' +import { Hono } from 'hono' +import { streamSSE } from 'hono/streaming' +import { serve } from '@hono/node-server' +import http from 'node:http' + +// ==================== Helpers ==================== + +interface TestServer { + port: number + close: () => void +} + +function startServer(app: Hono): Promise { + return new Promise((resolve) => { + const server = serve({ fetch: app.fetch, port: 0 }, (info) => { + resolve({ port: info.port, close: () => server.close() }) + }) + }) +} + +/** Minimal SSE client using raw http.get — no external dependencies. */ +function createSSEClient(url: string): { + events: string[] + connected: Promise + waitForEvents: (count: number, timeoutMs?: number) => Promise + close: () => void +} { + const events: string[] = [] + let req: http.ClientRequest | null = null + let resolveConnected: () => void + let rejectConnected: (err: Error) => void + const connected = new Promise((res, rej) => { + resolveConnected = res + rejectConnected = rej + }) + + // Waiters for specific event counts + let eventWaiter: { count: number; resolve: (events: string[]) => void; reject: (err: Error) => void } | null = null + + req = http.get(url, (res) => { + if (res.statusCode !== 200) { + rejectConnected(new Error(`SSE status ${res.statusCode}`)) + return + } + resolveConnected() + + let buffer = '' + res.on('data', (chunk: Buffer) => { + buffer += chunk.toString() + + // Parse SSE format: lines separated by \n\n + let idx: number + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + + // Extract data lines (skip event:, id:, etc.) + const dataLines = block.split('\n') + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trim()) + + if (dataLines.length > 0) { + const data = dataLines.join('\n') + if (data) { + events.push(data) + if (eventWaiter && events.length >= eventWaiter.count) { + eventWaiter.resolve([...events]) + eventWaiter = null + } + } + } + } + }) + }) + + req.on('error', (err) => rejectConnected(err)) + + return { + events, + connected, + waitForEvents(count: number, timeoutMs = 5000): Promise { + if (events.length >= count) return Promise.resolve([...events]) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + eventWaiter = null + reject(new Error(`SSE timeout: got ${events.length}/${count} events`)) + }, timeoutMs) + eventWaiter = { + count, + resolve: (evts) => { clearTimeout(timer); resolve(evts) }, + reject: (err) => { clearTimeout(timer); reject(err) }, + } + }) + }, + close() { + req?.destroy() + }, + } +} + +// ==================== Tests ==================== + +describe('SSE transport (real HTTP)', () => { + const servers: TestServer[] = [] + const clients: ReturnType[] = [] + + afterEach(() => { + clients.forEach(c => c.close()) + clients.length = 0 + servers.forEach(s => s.close()) + servers.length = 0 + }) + + it('writeSSE delivers events to client in real-time', async () => { + // Shared state between SSE and POST routes (same pattern as chat.ts) + type SSEClient = { id: string; send: (data: string) => void } + const sseClients = new Map() + + const app = new Hono() + + app.get('/events', (c) => { + return streamSSE(c, async (stream) => { + sseClients.set('test', { + id: 'test', + send: (data) => { stream.writeSSE({ data }).catch(() => {}) }, + }) + stream.onAbort(() => { sseClients.delete('test') }) + await new Promise(() => {}) // keep alive + }) + }) + + app.post('/send', async (c) => { + const { events } = await c.req.json() as { events: Array<{ type: string; [k: string]: unknown }> } + for (const event of events) { + const data = JSON.stringify({ type: 'stream', event }) + for (const client of sseClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + } + return c.json({ ok: true }) + }) + + const server = await startServer(app) + servers.push(server) + + // Connect SSE client + const sse = createSSEClient(`http://localhost:${server.port}/events`) + clients.push(sse) + await sse.connected + + // Small delay for client registration + await new Promise(r => setTimeout(r, 50)) + + // Send events via POST (simulating chat.ts handler) + const testEvents = [ + { type: 'tool_use', id: 't1', name: 'Read', input: { path: '/tmp' } }, + { type: 'tool_result', tool_use_id: 't1', content: 'file contents' }, + { type: 'text', text: 'Here is the file' }, + ] + + const waitPromise = sse.waitForEvents(3) + await fetch(`http://localhost:${server.port}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ events: testEvents }), + }) + + const received = await waitPromise + expect(received).toHaveLength(3) + + const parsed = received.map(e => JSON.parse(e)) + expect(parsed[0]).toEqual({ type: 'stream', event: testEvents[0] }) + expect(parsed[1]).toEqual({ type: 'stream', event: testEvents[1] }) + expect(parsed[2]).toEqual({ type: 'stream', event: testEvents[2] }) + }) + + it('fire-and-forget writeSSE still delivers events (race condition test)', async () => { + type SSEClient = { id: string; send: (data: string) => void } + const sseClients = new Map() + + const app = new Hono() + + app.get('/events', (c) => { + return streamSSE(c, async (stream) => { + sseClients.set('test', { + id: 'test', + send: (data) => { stream.writeSSE({ data }).catch(() => {}) }, + }) + stream.onAbort(() => { sseClients.delete('test') }) + await new Promise(() => {}) + }) + }) + + app.post('/send', async (c) => { + // Simulate chat.ts: fire-and-forget SSE writes, then return JSON + const events = [ + { type: 'tool_use', id: 't1', name: 'Test', input: {} }, + { type: 'tool_result', tool_use_id: 't1', content: 'result' }, + { type: 'text', text: 'done' }, + ] + for (const event of events) { + const data = JSON.stringify({ type: 'stream', event }) + for (const client of sseClients.values()) { + try { client.send(data) } catch {} + } + } + return c.json({ text: 'done' }) + }) + + const server = await startServer(app) + servers.push(server) + + const sse = createSSEClient(`http://localhost:${server.port}/events`) + clients.push(sse) + await sse.connected + await new Promise(r => setTimeout(r, 50)) + + const waitPromise = sse.waitForEvents(3) + + // POST and wait for response + const resp = await fetch(`http://localhost:${server.port}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const postResult = await resp.json() + expect(postResult).toEqual({ text: 'done' }) + + // SSE events should still arrive even though POST already returned + const received = await waitPromise + expect(received).toHaveLength(3) + + const types = received.map(e => JSON.parse(e).event.type) + expect(types).toEqual(['tool_use', 'tool_result', 'text']) + }) + + it('async events (simulating Claude Code CLI timing) arrive in order', async () => { + type SSEClient = { id: string; send: (data: string) => void } + const sseClients = new Map() + + const app = new Hono() + + app.get('/events', (c) => { + return streamSSE(c, async (stream) => { + sseClients.set('test', { + id: 'test', + send: (data) => { stream.writeSSE({ data }).catch(() => {}) }, + }) + stream.onAbort(() => { sseClients.delete('test') }) + await new Promise(() => {}) + }) + }) + + app.post('/send', async (c) => { + // Simulate Claude Code CLI: events arrive with async delays + const events = [ + { type: 'text', text: 'Let me check...' }, + { type: 'tool_use', id: 't1', name: 'Read', input: { path: '/tmp' } }, + { type: 'tool_result', tool_use_id: 't1', content: 'contents' }, + { type: 'text', text: 'Here are the contents' }, + ] + + for (const event of events) { + await new Promise(r => setTimeout(r, 10)) + const data = JSON.stringify({ type: 'stream', event }) + for (const client of sseClients.values()) { + try { client.send(data) } catch {} + } + } + + return c.json({ text: 'Here are the contents' }) + }) + + const server = await startServer(app) + servers.push(server) + + const sse = createSSEClient(`http://localhost:${server.port}/events`) + clients.push(sse) + await sse.connected + await new Promise(r => setTimeout(r, 50)) + + const waitPromise = sse.waitForEvents(4) + + await fetch(`http://localhost:${server.port}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + const received = await waitPromise + expect(received).toHaveLength(4) + + const types = received.map(e => JSON.parse(e).event.type) + expect(types).toEqual(['text', 'tool_use', 'tool_result', 'text']) + }) +}) From d7744c02754bb37e40a338b0c660c2b0df79f30c Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 19:19:34 +0800 Subject: [PATCH 04/12] docs: add v1 roadmap to README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index b2bf9cc4..e920b57b 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,15 @@ data/ docs/ # Architecture documentation ``` +## Roadmap to v1 + +Open Alice is in pre-release. The following items must land before the first stable version: + +- [ ] **Tool confirmation** — sensitive tools (order placement, cancellation, position close) require explicit user confirmation before execution, with a per-tool bypass mechanism for trusted workflows +- [ ] **Trading-as-Git stable interface** — finalize the stage → commit → push API surface (including `tradingStatus`, `tradingLog`, `tradingShow`, `tradingSync`) as a stable, versioned contract +- [ ] **IBKR adapter** — Interactive Brokers integration via the Client Portal or TWS API, adding a third trading backend alongside CCXT and Alpaca +- [ ] **Account snapshot & analytics** — unified trading account snapshots with P&L breakdown, exposure analysis, and historical performance tracking + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=TraderAlice/OpenAlice&type=Date)](https://star-history.com/#TraderAlice/OpenAlice&Date) \ No newline at end of file From a10c68a75d94149ebdd67b51ebb2a4b5d5eabbc2 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 19:27:20 +0800 Subject: [PATCH 05/12] refactor(ui): redesign portfolio positions table for multi-asset UTA support Remove derivative-specific columns (Side, Leverage) from the positions table. Replace with a rich Symbol cell that shows secType-aware badges (spot/swap/fut/opt), contextual side tags, and leverage indicators only when meaningful. Margin and liquidation info shown as secondary text within the row. Stocks now display cleanly without spurious "long 1x". Co-Authored-By: Claude Opus 4.6 --- ui/src/api/types.ts | 13 +++- ui/src/pages/PortfolioPage.tsx | 107 ++++++++++++++++++++++++--------- 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 8d46e5b6..9555af7f 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -162,7 +162,18 @@ export interface AccountInfo { } export interface Position { - contract: { aliceId?: string; symbol?: string; secType?: string; exchange?: string; currency?: string } + contract: { + aliceId?: string + symbol?: string + secType?: string + exchange?: string + currency?: string + lastTradeDateOrContractMonth?: string + strike?: number + right?: string + multiplier?: number + localSymbol?: string + } side: 'long' | 'short' qty: number avgEntryPrice: number diff --git a/ui/src/pages/PortfolioPage.tsx b/ui/src/pages/PortfolioPage.tsx index 3ac9c7fd..ee56d916 100644 --- a/ui/src/pages/PortfolioPage.tsx +++ b/ui/src/pages/PortfolioPage.tsx @@ -214,9 +214,38 @@ interface PositionWithAccount extends Position { accountProvider: string } -function PositionsTable({ positions }: { positions: PositionWithAccount[] }) { - const hasLeverage = positions.some(p => p.leverage > 1) +/** True when the position carries derivative-specific context worth showing (side/leverage). */ +function isDerivative(p: Position): boolean { + const t = p.contract.secType + if (t === 'FUT' || t === 'OPT' || t === 'FOP') return true + if (t === 'CRYPTO' && p.leverage > 1) return true + return p.side === 'short' +} + +/** Build display fragments for a contract based on its secType. */ +function contractDisplay(p: Position): { name: string; tag?: string } { + const c = p.contract + const sym = c.symbol ?? '???' + const t = c.secType + + if (t === 'OPT' || t === 'FOP') { + // Options: show localSymbol if available, else construct from parts + const optDesc = c.localSymbol + ?? [sym, c.lastTradeDateOrContractMonth, c.right, c.strike && fmt(c.strike)].filter(Boolean).join(' ') + return { name: optDesc, tag: 'opt' } + } + if (t === 'FUT') { + const expiry = c.lastTradeDateOrContractMonth + return { name: expiry ? `${sym} ${expiry}` : sym, tag: 'fut' } + } + if (t === 'CRYPTO') { + return { name: sym, tag: p.leverage > 1 ? 'swap' : 'spot' } + } + // STK, CASH, BOND, CMDTY, etc. — just the symbol, no tag + return { name: sym } +} +function PositionsTable({ positions }: { positions: PositionWithAccount[] }) { return (

@@ -227,41 +256,61 @@ function PositionsTable({ positions }: { positions: PositionWithAccount[] }) { Symbol - Side Qty - Entry + Avg Cost Current - {hasLeverage && Lev} - Cost Basis Mkt Value PnL PnL % - {positions.map((p, i) => ( - - - {p.contract.symbol} - {p.accountLabel} - - - {p.side} - - {fmtNum(p.qty)} - {fmt(p.avgEntryPrice)} - {fmt(p.currentPrice)} - {hasLeverage && {p.leverage}x} - {fmt(p.costBasis)} - {fmt(p.marketValue)} - = 0 ? 'text-green' : 'text-red'}`}> - {fmtPnl(p.unrealizedPnL)} - - = 0 ? 'text-green' : 'text-red'}`}> - {p.unrealizedPnLPercent >= 0 ? '+' : ''}{p.unrealizedPnLPercent.toFixed(2)}% - - - ))} + {positions.map((p, i) => { + const display = contractDisplay(p) + const deriv = isDerivative(p) + const hasMarginInfo = p.margin || p.liquidationPrice + + return ( + + + {/* Primary: symbol + inline badges */} +
+ {display.name} + {display.tag && ( + {display.tag} + )} + {deriv && ( + + {p.side} + + )} + {p.leverage > 1 && ( + {p.leverage}x + )} + {p.accountLabel} +
+ {/* Secondary: margin / liquidation for derivatives */} + {hasMarginInfo && ( +
+ {p.margin ? `Margin ${fmt(p.margin)}` : ''} + {p.margin && p.liquidationPrice ? ' \u00b7 ' : ''} + {p.liquidationPrice ? `Liq ${fmt(p.liquidationPrice)}` : ''} +
+ )} + + {fmtNum(p.qty)} + {fmt(p.avgEntryPrice)} + {fmt(p.currentPrice)} + {fmt(p.marketValue)} + = 0 ? 'text-green' : 'text-red'}`}> + {fmtPnl(p.unrealizedPnL)} + + = 0 ? 'text-green' : 'text-red'}`}> + {p.unrealizedPnLPercent >= 0 ? '+' : ''}{p.unrealizedPnLPercent.toFixed(2)}% + + + ) + })}

From 8e90eeda270e3ad9ba1a0e7a35c4a09b0427a4ab Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 19:27:34 +0800 Subject: [PATCH 06/12] refactor(streaming): POST /api/chat returns SSE stream instead of JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat events (tool_use, tool_result, text) now travel on the same HTTP connection as the request — no longer dependent on a separate SSE connection that can drop and lose events. Server: POST handler uses streamSSE() to push events directly, plus best-effort push to other SSE clients for multi-tab sync. Frontend: handleSend() uses fetch + ReadableStream to consume the streaming POST response. Removes streamToolsRef/streamTextRef bridge pattern. useSSE now only handles push notifications. Co-Authored-By: Claude Opus 4.6 --- src/connectors/web/routes/chat.ts | 44 ++++++++++------- ui/src/api/chat.ts | 54 +++++++++++++++++++-- ui/src/pages/ChatPage.tsx | 78 ++++++++++++++++--------------- 3 files changed, 120 insertions(+), 56 deletions(-) diff --git a/src/connectors/web/routes/chat.ts b/src/connectors/web/routes/chat.ts index 02c3ebaa..8cd59d71 100644 --- a/src/connectors/web/routes/chat.ts +++ b/src/connectors/web/routes/chat.ts @@ -55,28 +55,40 @@ export function createChatRoutes({ ctx, sessions, sseByChannel }: ChatDeps) { const stream = ctx.agentCenter.askWithSession(message, session, opts) - // Stream events to SSE clients for this channel as they arrive + // Stream events directly on the POST response (reliable, same connection). + // Also push to other SSE clients for multi-tab sync (best-effort). const channelClients = sseByChannel.get(channelId) ?? new Map() - for await (const event of stream) { - if (event.type === 'done') continue - const data = JSON.stringify({ type: 'stream', event }) - for (const client of channelClients.values()) { - try { client.send(data) } catch { /* disconnected */ } + + return streamSSE(c, async (sseStream) => { + for await (const event of stream) { + if (event.type === 'done') continue + const data = JSON.stringify({ type: 'stream', event }) + + // Write to requesting client (reliable) + await sseStream.writeSSE({ data }) + + // Push to other SSE clients (best-effort, multi-tab) + for (const client of channelClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } } - } - // Stream fully drained — await resolves immediately with cached result - const result = await stream + // Stream fully drained — await resolves immediately with cached result + const result = await stream - await ctx.eventLog.append('message.sent', { - channel: 'web', to: channelId, prompt: message, - reply: result.text, durationMs: Date.now() - receivedEntry.ts, - }) + await ctx.eventLog.append('message.sent', { + channel: 'web', to: channelId, prompt: message, + reply: result.text, durationMs: Date.now() - receivedEntry.ts, + }) - // Media already persisted by AgentCenter — use pre-built URLs - const media = (result.mediaUrls ?? []).map(url => ({ type: 'image', url })) + // Media already persisted by AgentCenter — use pre-built URLs + const media = (result.mediaUrls ?? []).map((url: string) => ({ type: 'image', url })) - return c.json({ text: result.text, media }) + // Final event with result + await sseStream.writeSSE({ + data: JSON.stringify({ type: 'done', text: result.text, media }), + }) + }) }) app.get('/history', async (c) => { diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts index aa333ebd..db7e1c32 100644 --- a/ui/src/api/chat.ts +++ b/ui/src/api/chat.ts @@ -1,18 +1,66 @@ import { headers } from './client' -import type { ChatResponse, ChatHistoryItem } from './types' +import type { ChatHistoryItem } from './types' + +// ==================== Stream event types ==================== + +export type ChatStreamEvent = + | { type: 'stream'; event: { type: 'tool_use'; id: string; name: string; input: unknown } } + | { type: 'stream'; event: { type: 'tool_result'; tool_use_id: string; content: string } } + | { type: 'stream'; event: { type: 'text'; text: string } } + | { type: 'done'; text: string; media: Array<{ type: string; url: string }> } + +// ==================== API ==================== export const chatApi = { - async send(message: string, channelId?: string): Promise { + /** + * Send a chat message and stream back events (SSE over POST). + * Yields tool_use, tool_result, text events as they arrive, + * then a final done event with the complete result. + */ + async *sendStreaming( + message: string, + channelId?: string, + signal?: AbortSignal, + ): AsyncGenerator { const res = await fetch('/api/chat', { method: 'POST', headers, body: JSON.stringify({ message, ...(channelId ? { channelId } : {}) }), + signal, }) if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) throw new Error(err.error || res.statusText) } - return res.json() + + // Parse SSE format from streaming POST response + const reader = res.body!.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + // SSE blocks are separated by \n\n + let idx: number + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + + // Extract data: lines from SSE block + const dataLines = block.split('\n') + .filter(l => l.startsWith('data:')) + .map(l => l.slice(5).trim()) + + if (dataLines.length > 0) { + try { + yield JSON.parse(dataLines.join('\n')) as ChatStreamEvent + } catch { /* ignore malformed events */ } + } + } + } }, async history(limit = 100, channel?: string): Promise<{ messages: ChatHistoryItem[] }> { diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index c82ab5b3..110919cc 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { api, type ToolCall, type StreamingToolCall } from '../api' +import { chatApi } from '../api/chat' import type { ChannelListItem } from '../api/channels' import { useSSE } from '../hooks/useSSE' import { ChatMessage, ToolCallGroup, ThinkingIndicator, StreamingToolGroup } from '../components/ChatMessage' @@ -24,9 +25,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { const [newMsgCount, setNewMsgCount] = useState(0) const [streamText, setStreamText] = useState('') const [streamTools, setStreamTools] = useState([]) - const streamTextRef = useRef('') - const streamToolsRef = useRef([]) - streamToolsRef.current = streamTools + const abortRef = useRef(null) // Popover state const [popoverOpen, setPopoverOpen] = useState(false) @@ -106,30 +105,11 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { }) }, [activeChannel]) - // SSE for the active channel + // SSE for push notifications only (heartbeat, cron, multi-tab sync) const sseChannel = activeChannel === 'default' ? undefined : activeChannel useSSE({ url: sseChannel ? `/api/chat/events?channel=${encodeURIComponent(sseChannel)}` : '/api/chat/events', onMessage: (data) => { - // Streaming events (tool_use / tool_result / text) during AI generation - if (data.type === 'stream' && data.event) { - const ev = data.event - if (ev.type === 'tool_use') { - setStreamTools((prev) => [...prev, { - id: ev.id, name: ev.name, input: ev.input, status: 'running', - }]) - } else if (ev.type === 'tool_result') { - setStreamTools((prev) => prev.map((t) => - t.id === ev.tool_use_id ? { ...t, status: 'done' as const, result: ev.content } : t, - )) - } else if (ev.type === 'text') { - streamTextRef.current += ev.text - setStreamText(streamTextRef.current) - } - return - } - - // Push notifications (heartbeat, cron, etc.) if (data.type === 'message' && data.text) { const role = data.kind === 'message' ? 'assistant' : 'notification' setMessages((prev) => [ @@ -144,31 +124,55 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { onStatus: activeChannel === 'default' ? onSSEStatus : undefined, }) - // Send message + // Abort streaming on unmount + useEffect(() => { + return () => { abortRef.current?.abort() } + }, []) + + // Send message — streams events directly from POST response (no SSE dependency) const handleSend = useCallback(async (text: string) => { - // Clear streaming state from previous round setStreamText('') setStreamTools([]) - streamTextRef.current = '' - setMessages((prev) => [...prev, { kind: 'text', role: 'user', text, _id: nextId.current++ }]) setIsWaiting(true) + const abort = new AbortController() + abortRef.current = abort + try { const channel = activeChannelRef.current === 'default' ? undefined : activeChannelRef.current - const data = await api.chat.send(text, channel) + let finalText = '' + let finalMedia: Array<{ type: string; url: string }> | undefined + const tools: StreamingToolCall[] = [] + let streamedText = '' + + for await (const event of chatApi.sendStreaming(text, channel, abort.signal)) { + if (event.type === 'stream') { + const ev = event.event + if (ev.type === 'tool_use') { + tools.push({ id: ev.id, name: ev.name, input: ev.input, status: 'running' }) + setStreamTools([...tools]) + } else if (ev.type === 'tool_result') { + const t = tools.find((tool) => tool.id === ev.tool_use_id) + if (t) { t.status = 'done'; t.result = ev.content } + setStreamTools([...tools]) + } else if (ev.type === 'text') { + streamedText += ev.text + setStreamText(streamedText) + } + } else if (event.type === 'done') { + finalText = event.text + finalMedia = event.media?.length ? event.media : undefined + } + } - // POST returned — persist streaming tool calls, then add final text - const tools = streamToolsRef.current + // Stream complete — finalize messages setStreamText('') setStreamTools([]) - streamTextRef.current = '' - if (data.text) { - const media = data.media?.length ? data.media : undefined + if (finalText) { setMessages((prev) => { const next = [...prev] - // Persist tool calls collected during streaming if (tools.length > 0) { next.push({ kind: 'tool_calls', @@ -180,7 +184,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { _id: nextId.current++, }) } - next.push({ kind: 'text', role: 'assistant', text: data.text, media, _id: nextId.current++ }) + next.push({ kind: 'text', role: 'assistant', text: finalText, media: finalMedia, _id: nextId.current++ }) return next }) if (userScrolledUp.current) { @@ -188,10 +192,9 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { } } } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return setStreamText('') setStreamTools([]) - streamTextRef.current = '' - const msg = err instanceof Error ? err.message : 'Unknown error' setMessages((prev) => [ ...prev, @@ -199,6 +202,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { ]) } finally { setIsWaiting(false) + abortRef.current = null } }, []) From 0bb0b9e432f2d4794c052d40c1bb20200388d7a5 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 19:31:11 +0800 Subject: [PATCH 07/12] test: add streaming POST SSE transport tests (3 tests) Covers the new architecture where POST /api/chat returns an SSE stream: - POST returns text/event-stream with stream events + done event - Incremental delivery (ReadableStream parsing, same as frontend) - Multi-tab: POST SSE stream + separate SSE clients both receive events Co-Authored-By: Claude Opus 4.6 --- .../web/__tests__/sse-transport.spec.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/src/connectors/web/__tests__/sse-transport.spec.ts b/src/connectors/web/__tests__/sse-transport.spec.ts index 67d56ac5..d62ae2ea 100644 --- a/src/connectors/web/__tests__/sse-transport.spec.ts +++ b/src/connectors/web/__tests__/sse-transport.spec.ts @@ -303,4 +303,186 @@ describe('SSE transport (real HTTP)', () => { const types = received.map(e => JSON.parse(e).event.type) expect(types).toEqual(['text', 'tool_use', 'tool_result', 'text']) }) + + // ==================== Streaming POST tests (new architecture) ==================== + + it('POST returns SSE stream with events and done (streaming POST pattern)', async () => { + const app = new Hono() + + app.post('/chat', (c) => { + return streamSSE(c, async (sseStream) => { + const events = [ + { type: 'tool_use', id: 't1', name: 'getQuote', input: { symbol: 'AAPL' } }, + { type: 'tool_result', tool_use_id: 't1', content: '{"price": 185}' }, + { type: 'text', text: 'AAPL is at $185' }, + ] + for (const event of events) { + await sseStream.writeSSE({ data: JSON.stringify({ type: 'stream', event }) }) + } + await sseStream.writeSSE({ + data: JSON.stringify({ type: 'done', text: 'AAPL is at $185', media: [] }), + }) + }) + }) + + const server = await startServer(app) + servers.push(server) + + // Read SSE from POST response body (same as frontend sendStreaming) + const res = await fetch(`http://localhost:${server.port}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'check AAPL' }), + }) + + expect(res.ok).toBe(true) + expect(res.headers.get('content-type')).toContain('text/event-stream') + + const text = await res.text() + const events = text + .split('\n\n') + .filter(block => block.trim()) + .map(block => { + const dataLine = block.split('\n').find(l => l.startsWith('data:')) + return dataLine ? JSON.parse(dataLine.slice(5).trim()) : null + }) + .filter(Boolean) + + expect(events).toHaveLength(4) + expect(events[0]).toEqual({ type: 'stream', event: { type: 'tool_use', id: 't1', name: 'getQuote', input: { symbol: 'AAPL' } } }) + expect(events[1]).toEqual({ type: 'stream', event: { type: 'tool_result', tool_use_id: 't1', content: '{"price": 185}' } }) + expect(events[2]).toEqual({ type: 'stream', event: { type: 'text', text: 'AAPL is at $185' } }) + expect(events[3]).toEqual({ type: 'done', text: 'AAPL is at $185', media: [] }) + }) + + it('POST SSE stream delivers events incrementally (not buffered)', async () => { + const app = new Hono() + + app.post('/chat', (c) => { + return streamSSE(c, async (sseStream) => { + await sseStream.writeSSE({ data: JSON.stringify({ type: 'stream', event: { type: 'text', text: 'thinking...' } }) }) + await new Promise(r => setTimeout(r, 50)) + await sseStream.writeSSE({ data: JSON.stringify({ type: 'stream', event: { type: 'tool_use', id: 't1', name: 'Read', input: {} } }) }) + await new Promise(r => setTimeout(r, 50)) + await sseStream.writeSSE({ data: JSON.stringify({ type: 'stream', event: { type: 'tool_result', tool_use_id: 't1', content: 'data' } }) }) + await new Promise(r => setTimeout(r, 50)) + await sseStream.writeSSE({ data: JSON.stringify({ type: 'done', text: 'done', media: [] }) }) + }) + }) + + const server = await startServer(app) + servers.push(server) + + const res = await fetch(`http://localhost:${server.port}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'test' }), + }) + + // Parse SSE incrementally using ReadableStream (same as frontend sendStreaming) + const reader = res.body!.getReader() + const decoder = new TextDecoder() + const parsed: unknown[] = [] + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + let idx: number + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + const dataLines = block.split('\n') + .filter(l => l.startsWith('data:')) + .map(l => l.slice(5).trim()) + if (dataLines.length > 0) { + try { parsed.push(JSON.parse(dataLines.join('\n'))) } catch {} + } + } + } + + expect(parsed).toHaveLength(4) + const types = parsed.map((e: any) => e.type === 'stream' ? e.event.type : e.type) + expect(types).toEqual(['text', 'tool_use', 'tool_result', 'done']) + }) + + it('POST SSE stream + separate SSE clients both receive events (multi-tab)', async () => { + type Client = { id: string; send: (data: string) => void } + const sseClients = new Map() + + const app = new Hono() + + // Separate SSE endpoint (for other tabs) + app.get('/events', (c) => { + return streamSSE(c, async (stream) => { + sseClients.set('tab2', { + id: 'tab2', + send: (data) => { stream.writeSSE({ data }).catch(() => {}) }, + }) + stream.onAbort(() => { sseClients.delete('tab2') }) + await new Promise(() => {}) + }) + }) + + // POST returns SSE stream (for requesting tab) + pushes to other clients + app.post('/chat', (c) => { + return streamSSE(c, async (sseStream) => { + const events = [ + { type: 'tool_use', id: 't1', name: 'Test', input: {} }, + { type: 'tool_result', tool_use_id: 't1', content: 'ok' }, + { type: 'text', text: 'result' }, + ] + for (const event of events) { + const data = JSON.stringify({ type: 'stream', event }) + await sseStream.writeSSE({ data }) + // Push to other SSE clients (multi-tab) + for (const client of sseClients.values()) { + try { client.send(data) } catch {} + } + } + await sseStream.writeSSE({ + data: JSON.stringify({ type: 'done', text: 'result', media: [] }), + }) + }) + }) + + const server = await startServer(app) + servers.push(server) + + // Tab 2: connect via SSE + const tab2 = createSSEClient(`http://localhost:${server.port}/events`) + clients.push(tab2) + await tab2.connected + await new Promise(r => setTimeout(r, 50)) + + // Tab 1: POST (gets SSE stream back) + const tab2Wait = tab2.waitForEvents(3) + const res = await fetch(`http://localhost:${server.port}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'test' }), + }) + + // Tab 1: verify POST SSE stream + const postText = await res.text() + const postEvents = postText + .split('\n\n') + .filter(b => b.trim()) + .map(b => { + const d = b.split('\n').find(l => l.startsWith('data:')) + return d ? JSON.parse(d.slice(5).trim()) : null + }) + .filter(Boolean) + + expect(postEvents).toHaveLength(4) // 3 stream + 1 done + expect(postEvents[3]).toEqual({ type: 'done', text: 'result', media: [] }) + + // Tab 2: verify SSE events arrived + const tab2Events = await tab2Wait + expect(tab2Events).toHaveLength(3) + const tab2Types = tab2Events.map(e => JSON.parse(e).event.type) + expect(tab2Types).toEqual(['tool_use', 'tool_result', 'text']) + }) }) From cf9f42b00e6d88aabb3e3d0d55268ab0a5aa4274 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 19:40:52 +0800 Subject: [PATCH 08/12] fix(ui): preserve content block order in streaming (text before tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace flat streamText/streamTools state with interleaved StreamSegment[] that preserves arrival order. Text blocks now render before tool calls (边想边做 pattern), and thinking dots persist while tools are running. Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/ChatPage.tsx | 98 +++++++++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 35 deletions(-) diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index 110919cc..c70128da 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -12,6 +12,11 @@ type DisplayItem = | { kind: 'text'; role: 'user' | 'assistant' | 'notification'; text: string; timestamp?: string | null; media?: Array<{ type: string; url: string }>; _id: number } | { kind: 'tool_calls'; calls: ToolCall[]; timestamp?: string; _id: number } +/** Interleaved streaming segment — preserves text/tool arrival order. */ +type StreamSegment = + | { kind: 'text'; text: string } + | { kind: 'tools'; tools: StreamingToolCall[] } + interface ChatPageProps { onSSEStatus?: (connected: boolean) => void } @@ -23,8 +28,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { const [isWaiting, setIsWaiting] = useState(false) const [showScrollBtn, setShowScrollBtn] = useState(false) const [newMsgCount, setNewMsgCount] = useState(0) - const [streamText, setStreamText] = useState('') - const [streamTools, setStreamTools] = useState([]) + const [streamSegments, setStreamSegments] = useState([]) const abortRef = useRef(null) // Popover state @@ -68,7 +72,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { } }, []) - useEffect(scrollToBottom, [messages, isWaiting, streamText, streamTools, scrollToBottom]) + useEffect(scrollToBottom, [messages, isWaiting, streamSegments, scrollToBottom]) // Detect user scroll useEffect(() => { @@ -131,8 +135,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { // Send message — streams events directly from POST response (no SSE dependency) const handleSend = useCallback(async (text: string) => { - setStreamText('') - setStreamTools([]) + setStreamSegments([]) setMessages((prev) => [...prev, { kind: 'text', role: 'user', text, _id: nextId.current++ }]) setIsWaiting(true) @@ -143,22 +146,35 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { const channel = activeChannelRef.current === 'default' ? undefined : activeChannelRef.current let finalText = '' let finalMedia: Array<{ type: string; url: string }> | undefined - const tools: StreamingToolCall[] = [] - let streamedText = '' + const segments: StreamSegment[] = [] for await (const event of chatApi.sendStreaming(text, channel, abort.signal)) { if (event.type === 'stream') { const ev = event.event if (ev.type === 'tool_use') { - tools.push({ id: ev.id, name: ev.name, input: ev.input, status: 'running' }) - setStreamTools([...tools]) + const last = segments[segments.length - 1] + if (last?.kind === 'tools') { + last.tools.push({ id: ev.id, name: ev.name, input: ev.input, status: 'running' }) + } else { + segments.push({ kind: 'tools', tools: [{ id: ev.id, name: ev.name, input: ev.input, status: 'running' }] }) + } + setStreamSegments([...segments]) } else if (ev.type === 'tool_result') { - const t = tools.find((tool) => tool.id === ev.tool_use_id) - if (t) { t.status = 'done'; t.result = ev.content } - setStreamTools([...tools]) + for (const seg of segments) { + if (seg.kind === 'tools') { + const t = seg.tools.find((tool) => tool.id === ev.tool_use_id) + if (t) { t.status = 'done'; t.result = ev.content; break } + } + } + setStreamSegments([...segments]) } else if (ev.type === 'text') { - streamedText += ev.text - setStreamText(streamedText) + const last = segments[segments.length - 1] + if (last?.kind === 'text') { + last.text += ev.text + } else { + segments.push({ kind: 'text', text: ev.text }) + } + setStreamSegments([...segments]) } } else if (event.type === 'done') { finalText = event.text @@ -167,16 +183,17 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { } // Stream complete — finalize messages - setStreamText('') - setStreamTools([]) + setStreamSegments([]) if (finalText) { + // Collect all tools across segments for the final tool_calls display item + const allTools = segments.flatMap((s) => s.kind === 'tools' ? s.tools : []) setMessages((prev) => { const next = [...prev] - if (tools.length > 0) { + if (allTools.length > 0) { next.push({ kind: 'tool_calls', - calls: tools.map((t) => ({ + calls: allTools.map((t) => ({ name: t.name, input: typeof t.input === 'string' ? t.input : JSON.stringify(t.input ?? ''), result: t.result, @@ -193,8 +210,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return - setStreamText('') - setStreamTools([]) + setStreamSegments([]) const msg = err instanceof Error ? err.message : 'Unknown error' setMessages((prev) => [ ...prev, @@ -444,7 +460,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { })} {isWaiting && (
0 ? 'mt-5' : ''}`}> - {streamTools.length > 0 || streamText ? ( + {streamSegments.length > 0 ? ( <>
@@ -454,21 +470,33 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) {
Alice
- {streamTools.length > 0 && } - {streamText ? ( -
- -
- ) : streamTools.length > 0 && streamTools.every((t) => t.status === 'done') ? ( - /* All tools finished but text hasn't arrived yet — show thinking dots */ -
-
- . - . - . + {streamSegments.map((seg, i) => + seg.kind === 'tools' ? ( +
0 ? 'mt-1' : ''}> +
-
- ) : null} + ) : ( +
0 ? 'mt-1' : ''}> + +
+ ), + )} + {/* Show thinking dots when last segment is tools with all done, or tools still running */} + {(() => { + const last = streamSegments[streamSegments.length - 1] + if (last?.kind === 'tools' && last.tools.every((t) => t.status === 'done')) { + return ( +
+
+ . + . + . +
+
+ ) + } + return null + })()} ) : ( From dac8be6e053f7b6ad586b8bbeb50d5f71742799e Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 19:53:22 +0800 Subject: [PATCH 09/12] refactor(ui): extract useChat hook with pure reducer + test coverage (17 tests) Extract chat logic from ChatPage into useChat hook: - reduceStreamEvent: pure function for interleaved segment building - finalizeMessages: preserves segment order in final display items (fixes intermediate text blocks being lost on stream end) - ChatPage simplified to consume hook Co-Authored-By: Claude Opus 4.6 --- ui/src/hooks/__tests__/useChat.spec.ts | 242 +++++++++++++++++++++++++ ui/src/hooks/useChat.ts | 198 ++++++++++++++++++++ ui/src/pages/ChatPage.tsx | 158 ++-------------- 3 files changed, 451 insertions(+), 147 deletions(-) create mode 100644 ui/src/hooks/__tests__/useChat.spec.ts create mode 100644 ui/src/hooks/useChat.ts diff --git a/ui/src/hooks/__tests__/useChat.spec.ts b/ui/src/hooks/__tests__/useChat.spec.ts new file mode 100644 index 00000000..979e00cf --- /dev/null +++ b/ui/src/hooks/__tests__/useChat.spec.ts @@ -0,0 +1,242 @@ +import { describe, it, expect } from 'vitest' +import { reduceStreamEvent, finalizeMessages } from '../useChat' + +// ==================== reduceStreamEvent ==================== + +describe('reduceStreamEvent', () => { + it('text event creates a new text segment', () => { + const result = reduceStreamEvent([], { type: 'text', text: 'hello' }) + expect(result).toEqual([{ kind: 'text', text: 'hello' }]) + }) + + it('consecutive text events merge into one segment', () => { + const s1 = reduceStreamEvent([], { type: 'text', text: 'hel' }) + const s2 = reduceStreamEvent(s1, { type: 'text', text: 'lo' }) + expect(s2).toEqual([{ kind: 'text', text: 'hello' }]) + }) + + it('tool_use after text creates a new tools segment', () => { + const s1 = reduceStreamEvent([], { type: 'text', text: 'thinking...' }) + const s2 = reduceStreamEvent(s1, { + type: 'tool_use', id: 't1', name: 'read_file', input: { path: '/foo' }, + }) + expect(s2).toHaveLength(2) + expect(s2[0]).toEqual({ kind: 'text', text: 'thinking...' }) + expect(s2[1]).toEqual({ + kind: 'tools', + tools: [{ id: 't1', name: 'read_file', input: { path: '/foo' }, status: 'running' }], + }) + }) + + it('consecutive tool_use events merge into one tools segment', () => { + const s1 = reduceStreamEvent([], { + type: 'tool_use', id: 't1', name: 'read', input: 'a', + }) + const s2 = reduceStreamEvent(s1, { + type: 'tool_use', id: 't2', name: 'write', input: 'b', + }) + expect(s2).toHaveLength(1) + expect(s2[0].kind).toBe('tools') + if (s2[0].kind === 'tools') { + expect(s2[0].tools).toHaveLength(2) + expect(s2[0].tools[0].name).toBe('read') + expect(s2[0].tools[1].name).toBe('write') + } + }) + + it('text after tools creates a new text segment (边想边做)', () => { + let segs = reduceStreamEvent([], { type: 'text', text: 'Let me check' }) + segs = reduceStreamEvent(segs, { type: 'tool_use', id: 't1', name: 'search', input: {} }) + segs = reduceStreamEvent(segs, { type: 'text', text: 'Found it' }) + expect(segs).toHaveLength(3) + expect(segs[0]).toEqual({ kind: 'text', text: 'Let me check' }) + expect(segs[1].kind).toBe('tools') + expect(segs[2]).toEqual({ kind: 'text', text: 'Found it' }) + }) + + it('tool_result marks the correct tool as done', () => { + let segs = reduceStreamEvent([], { type: 'tool_use', id: 't1', name: 'read', input: '' }) + segs = reduceStreamEvent(segs, { type: 'tool_use', id: 't2', name: 'write', input: '' }) + segs = reduceStreamEvent(segs, { type: 'tool_result', tool_use_id: 't1', content: 'file contents' }) + if (segs[0].kind === 'tools') { + expect(segs[0].tools[0].status).toBe('done') + expect(segs[0].tools[0].result).toBe('file contents') + expect(segs[0].tools[1].status).toBe('running') + } + }) + + it('tool_result finds tools across multiple segments', () => { + let segs = reduceStreamEvent([], { type: 'tool_use', id: 't1', name: 'read', input: '' }) + segs = reduceStreamEvent(segs, { type: 'tool_result', tool_use_id: 't1', content: 'ok' }) + segs = reduceStreamEvent(segs, { type: 'text', text: 'now writing' }) + segs = reduceStreamEvent(segs, { type: 'tool_use', id: 't2', name: 'write', input: '' }) + segs = reduceStreamEvent(segs, { type: 'tool_result', tool_use_id: 't2', content: 'written' }) + + // t2 is in the second tools segment (index 2) + expect(segs).toHaveLength(3) + if (segs[2].kind === 'tools') { + expect(segs[2].tools[0].status).toBe('done') + expect(segs[2].tools[0].result).toBe('written') + } + }) + + it('tool_result for unknown id is a no-op', () => { + const segs = reduceStreamEvent( + [{ kind: 'tools', tools: [{ id: 't1', name: 'read', input: '', status: 'running' }] }], + { type: 'tool_result', tool_use_id: 'unknown', content: 'nope' }, + ) + if (segs[0].kind === 'tools') { + expect(segs[0].tools[0].status).toBe('running') + expect(segs[0].tools[0].result).toBeUndefined() + } + }) + + it('full interleaved sequence produces correct structure', () => { + const events = [ + { type: 'text' as const, text: 'Let me look into this.' }, + { type: 'tool_use' as const, id: 't1', name: 'search', input: { q: 'bug' } }, + { type: 'tool_result' as const, tool_use_id: 't1', content: 'found 3 results' }, + { type: 'text' as const, text: 'I see the issue. Let me fix it.' }, + { type: 'tool_use' as const, id: 't2', name: 'edit', input: { file: 'a.ts' } }, + { type: 'tool_result' as const, tool_use_id: 't2', content: 'edited' }, + ] + + let segs = reduceStreamEvent([], events[0]) + for (let i = 1; i < events.length; i++) { + segs = reduceStreamEvent(segs, events[i]) + } + + expect(segs).toHaveLength(4) + expect(segs[0]).toEqual({ kind: 'text', text: 'Let me look into this.' }) + expect(segs[1].kind).toBe('tools') + if (segs[1].kind === 'tools') { + expect(segs[1].tools[0].name).toBe('search') + expect(segs[1].tools[0].status).toBe('done') + expect(segs[1].tools[0].result).toBe('found 3 results') + } + expect(segs[2]).toEqual({ kind: 'text', text: 'I see the issue. Let me fix it.' }) + expect(segs[3].kind).toBe('tools') + if (segs[3].kind === 'tools') { + expect(segs[3].tools[0].name).toBe('edit') + expect(segs[3].tools[0].status).toBe('done') + } + }) + + it('does not mutate the input segments array', () => { + const original = [{ kind: 'text' as const, text: 'hello' }] + const frozen = [...original] + const result = reduceStreamEvent(original, { type: 'text', text: ' world' }) + expect(original).toEqual(frozen) + expect(result).not.toBe(original) + }) +}) + +// ==================== finalizeMessages ==================== + +describe('finalizeMessages', () => { + let idCounter = 0 + const idGen = () => idCounter++ + + it('returns empty array when finalText is empty', () => { + const result = finalizeMessages([], '', undefined, idGen) + expect(result).toEqual([]) + }) + + it('text-only response produces a single assistant message', () => { + idCounter = 100 + const result = finalizeMessages([], 'Hello!', undefined, idGen) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + kind: 'text', role: 'assistant', text: 'Hello!', media: undefined, _id: 100, + }) + }) + + it('preserves interleaved text → tools order', () => { + idCounter = 200 + const segments = [ + { kind: 'text' as const, text: 'thinking' }, + { + kind: 'tools' as const, + tools: [ + { id: 't1', name: 'read_file', input: '/foo', status: 'done' as const, result: 'contents' }, + ], + }, + ] + const result = finalizeMessages(segments, 'Done reading.', undefined, idGen) + expect(result).toHaveLength(3) + expect(result[0]).toMatchObject({ kind: 'text', role: 'assistant', text: 'thinking' }) + expect(result[1].kind).toBe('tool_calls') + if (result[1].kind === 'tool_calls') { + expect(result[1].calls).toEqual([ + { name: 'read_file', input: '/foo', result: 'contents' }, + ]) + } + // finalText appended as new text item (last segment was tools, not text) + expect(result[2]).toMatchObject({ kind: 'text', role: 'assistant', text: 'Done reading.' }) + }) + + it('serializes non-string tool input to JSON', () => { + idCounter = 300 + const segments = [ + { + kind: 'tools' as const, + tools: [ + { id: 't1', name: 'search', input: { query: 'bug' }, status: 'done' as const, result: 'ok' }, + ], + }, + ] + const result = finalizeMessages(segments, 'Found it.', undefined, idGen) + // tools segment is first, then finalText appended + expect(result[0].kind).toBe('tool_calls') + if (result[0].kind === 'tool_calls') { + expect(result[0].calls[0].input).toBe('{"query":"bug"}') + } + }) + + it('passes through media array', () => { + idCounter = 400 + const media = [{ type: 'image', url: '/img.png' }] + const result = finalizeMessages([], 'Here is an image.', media, idGen) + expect(result[0]).toMatchObject({ + kind: 'text', role: 'assistant', media, + }) + }) + + it('preserves interleaved tools → text → tools structure', () => { + idCounter = 500 + const segments = [ + { kind: 'tools' as const, tools: [{ id: 't1', name: 'a', input: '', status: 'done' as const }] }, + { kind: 'text' as const, text: 'middle' }, + { kind: 'tools' as const, tools: [{ id: 't2', name: 'b', input: '', status: 'done' as const }] }, + ] + const result = finalizeMessages(segments, 'All done.', undefined, idGen) + // Each segment becomes its own DisplayItem, plus finalText appended + expect(result).toHaveLength(4) + expect(result[0].kind).toBe('tool_calls') + if (result[0].kind === 'tool_calls') { + expect(result[0].calls).toHaveLength(1) + expect(result[0].calls[0].name).toBe('a') + } + expect(result[1]).toMatchObject({ kind: 'text', role: 'assistant', text: 'middle' }) + expect(result[2].kind).toBe('tool_calls') + if (result[2].kind === 'tool_calls') { + expect(result[2].calls).toHaveLength(1) + expect(result[2].calls[0].name).toBe('b') + } + // finalText replaces nothing (last segment was tools), so appended + expect(result[3]).toMatchObject({ kind: 'text', role: 'assistant', text: 'All done.' }) + }) + + it('replaces last text segment with finalText when last segment is text', () => { + idCounter = 600 + const segments = [ + { kind: 'tools' as const, tools: [{ id: 't1', name: 'read', input: '', status: 'done' as const }] }, + { kind: 'text' as const, text: 'streaming partial...' }, + ] + const result = finalizeMessages(segments, 'Complete final text.', undefined, idGen) + expect(result).toHaveLength(2) + expect(result[0].kind).toBe('tool_calls') + // Last text segment replaced with authoritative finalText + expect(result[1]).toMatchObject({ kind: 'text', role: 'assistant', text: 'Complete final text.' }) + }) +}) diff --git a/ui/src/hooks/useChat.ts b/ui/src/hooks/useChat.ts new file mode 100644 index 00000000..6cc1f592 --- /dev/null +++ b/ui/src/hooks/useChat.ts @@ -0,0 +1,198 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { chatApi } from '../api/chat' +import type { ChatStreamEvent } from '../api/chat' +import type { ToolCall, StreamingToolCall } from '../api/types' +import { useSSE } from './useSSE' + +// ==================== Types ==================== + +export type DisplayItem = + | { kind: 'text'; role: 'user' | 'assistant' | 'notification'; text: string; timestamp?: string | null; media?: Array<{ type: string; url: string }>; _id: number } + | { kind: 'tool_calls'; calls: ToolCall[]; timestamp?: string; _id: number } + +export type StreamSegment = + | { kind: 'text'; text: string } + | { kind: 'tools'; tools: StreamingToolCall[] } + +// ==================== Pure reducers ==================== + +type StreamEventPayload = Extract['event'] + +export function reduceStreamEvent(segments: StreamSegment[], ev: StreamEventPayload): StreamSegment[] { + const next = segments.map((s): StreamSegment => + s.kind === 'text' ? { ...s } : { ...s, tools: [...s.tools] }, + ) + + if (ev.type === 'tool_use') { + const last = next[next.length - 1] + if (last?.kind === 'tools') { + last.tools.push({ id: ev.id, name: ev.name, input: ev.input, status: 'running' }) + } else { + next.push({ kind: 'tools', tools: [{ id: ev.id, name: ev.name, input: ev.input, status: 'running' }] }) + } + } else if (ev.type === 'tool_result') { + for (const seg of next) { + if (seg.kind === 'tools') { + const t = seg.tools.find((tool) => tool.id === ev.tool_use_id) + if (t) { t.status = 'done'; t.result = ev.content; break } + } + } + } else if (ev.type === 'text') { + const last = next[next.length - 1] + if (last?.kind === 'text') { + last.text += ev.text + } else { + next.push({ kind: 'text', text: ev.text }) + } + } + + return next +} + +export function finalizeMessages( + segments: StreamSegment[], + finalText: string, + finalMedia: Array<{ type: string; url: string }> | undefined, + idGen: () => number, +): DisplayItem[] { + if (!finalText) return [] + const items: DisplayItem[] = [] + + // Preserve interleaved order: emit each segment as a DisplayItem + for (const seg of segments) { + if (seg.kind === 'tools') { + items.push({ + kind: 'tool_calls', + calls: seg.tools.map((t) => ({ + name: t.name, + input: typeof t.input === 'string' ? t.input : JSON.stringify(t.input ?? ''), + result: t.result, + })), + _id: idGen(), + }) + } else { + items.push({ kind: 'text', role: 'assistant', text: seg.text, _id: idGen() }) + } + } + + // Final text from the done event (the complete response) + // If the last segment was already a text block, replace it with finalText + media + // (the done event's text is the authoritative final version) + const lastItem = items[items.length - 1] + if (lastItem?.kind === 'text' && lastItem.role === 'assistant') { + lastItem.text = finalText + lastItem.media = finalMedia + } else { + items.push({ kind: 'text', role: 'assistant', text: finalText, media: finalMedia, _id: idGen() }) + } + + return items +} + +// ==================== Hook ==================== + +interface UseChatOptions { + channel: string + onSSEStatus?: (connected: boolean) => void +} + +export interface UseChatReturn { + messages: DisplayItem[] + streamSegments: StreamSegment[] + isWaiting: boolean + send: (text: string) => Promise + abort: () => void +} + +export function useChat({ channel, onSSEStatus }: UseChatOptions): UseChatReturn { + const [messages, setMessages] = useState([]) + const [streamSegments, setStreamSegments] = useState([]) + const [isWaiting, setIsWaiting] = useState(false) + const abortRef = useRef(null) + const nextId = useRef(0) + const channelRef = useRef(channel) + channelRef.current = channel + + // Load chat history when channel changes + useEffect(() => { + const ch = channel === 'default' ? undefined : channel + chatApi.history(100, ch).then(({ messages: msgs }) => { + setMessages(msgs.map((m): DisplayItem => { + if (m.kind === 'text' && m.metadata?.kind === 'notification') { + return { ...m, role: 'notification', _id: nextId.current++ } + } + return { ...m, _id: nextId.current++ } + })) + }).catch((err) => { + console.warn('Failed to load history:', err) + }) + }, [channel]) + + // SSE for push notifications (heartbeat, cron, multi-tab sync) + const sseChannel = channel === 'default' ? undefined : channel + useSSE({ + url: sseChannel ? `/api/chat/events?channel=${encodeURIComponent(sseChannel)}` : '/api/chat/events', + onMessage: (data) => { + if (data.type === 'message' && data.text) { + const role = data.kind === 'message' ? 'assistant' : 'notification' + setMessages((prev) => [ + ...prev, + { kind: 'text', role, text: data.text, media: data.media, _id: nextId.current++ }, + ]) + } + }, + onStatus: channel === 'default' ? onSSEStatus : undefined, + }) + + // Abort streaming on unmount + useEffect(() => { + return () => { abortRef.current?.abort() } + }, []) + + const send = useCallback(async (text: string) => { + setStreamSegments([]) + setMessages((prev) => [...prev, { kind: 'text', role: 'user', text, _id: nextId.current++ }]) + setIsWaiting(true) + + const abort = new AbortController() + abortRef.current = abort + + try { + const ch = channelRef.current === 'default' ? undefined : channelRef.current + let finalText = '' + let finalMedia: Array<{ type: string; url: string }> | undefined + let segments: StreamSegment[] = [] + + for await (const event of chatApi.sendStreaming(text, ch, abort.signal)) { + if (event.type === 'stream') { + segments = reduceStreamEvent(segments, event.event) + setStreamSegments(segments) + } else if (event.type === 'done') { + finalText = event.text + finalMedia = event.media?.length ? event.media : undefined + } + } + + setStreamSegments([]) + const newItems = finalizeMessages(segments, finalText, finalMedia, () => nextId.current++) + if (newItems.length > 0) { + setMessages((prev) => [...prev, ...newItems]) + } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + setStreamSegments([]) + const msg = err instanceof Error ? err.message : 'Unknown error' + setMessages((prev) => [ + ...prev, + { kind: 'text', role: 'notification', text: `Error: ${msg}`, _id: nextId.current++ }, + ]) + } finally { + setIsWaiting(false) + abortRef.current = null + } + }, []) + + const abortFn = useCallback(() => { abortRef.current?.abort() }, []) + + return { messages, streamSegments, isWaiting, send, abort: abortFn } +} diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index c70128da..cd89206c 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -1,22 +1,11 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { api, type ToolCall, type StreamingToolCall } from '../api' -import { chatApi } from '../api/chat' +import { api } from '../api' import type { ChannelListItem } from '../api/channels' -import { useSSE } from '../hooks/useSSE' +import { useChat } from '../hooks/useChat' import { ChatMessage, ToolCallGroup, ThinkingIndicator, StreamingToolGroup } from '../components/ChatMessage' import { ChatInput } from '../components/ChatInput' import { ChannelConfigModal } from '../components/ChannelConfigModal' -/** Unified display item for the message list. */ -type DisplayItem = - | { kind: 'text'; role: 'user' | 'assistant' | 'notification'; text: string; timestamp?: string | null; media?: Array<{ type: string; url: string }>; _id: number } - | { kind: 'tool_calls'; calls: ToolCall[]; timestamp?: string; _id: number } - -/** Interleaved streaming segment — preserves text/tool arrival order. */ -type StreamSegment = - | { kind: 'text'; text: string } - | { kind: 'tools'; tools: StreamingToolCall[] } - interface ChatPageProps { onSSEStatus?: (connected: boolean) => void } @@ -24,12 +13,13 @@ interface ChatPageProps { export function ChatPage({ onSSEStatus }: ChatPageProps) { const [channels, setChannels] = useState([{ id: 'default', label: 'Alice' }]) const [activeChannel, setActiveChannel] = useState('default') - const [messages, setMessages] = useState([]) - const [isWaiting, setIsWaiting] = useState(false) const [showScrollBtn, setShowScrollBtn] = useState(false) const [newMsgCount, setNewMsgCount] = useState(0) - const [streamSegments, setStreamSegments] = useState([]) - const abortRef = useRef(null) + + const { messages, streamSegments, isWaiting, send, abort } = useChat({ + channel: activeChannel, + onSSEStatus: activeChannel === 'default' ? onSSEStatus : undefined, + }) // Popover state const [popoverOpen, setPopoverOpen] = useState(false) @@ -40,12 +30,9 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { const [editingChannel, setEditingChannel] = useState(null) const popoverRef = useRef(null) - const nextId = useRef(0) const messagesEndRef = useRef(null) const userScrolledUp = useRef(false) const containerRef = useRef(null) - const activeChannelRef = useRef(activeChannel) - activeChannelRef.current = activeChannel const isOnSubChannel = activeChannel !== 'default' const subChannels = channels.filter((ch) => ch.id !== 'default') @@ -94,133 +81,10 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { api.channels.list().then(({ channels: ch }) => setChannels(ch)).catch(() => {}) }, []) - // Load chat history when active channel changes + // Cleanup abort on unmount useEffect(() => { - const channel = activeChannel === 'default' ? undefined : activeChannel - api.chat.history(100, channel).then(({ messages: msgs }) => { - setMessages(msgs.map((m): DisplayItem => { - if (m.kind === 'text' && m.metadata?.kind === 'notification') { - return { ...m, role: 'notification', _id: nextId.current++ } - } - return { ...m, _id: nextId.current++ } - })) - }).catch((err) => { - console.warn('Failed to load history:', err) - }) - }, [activeChannel]) - - // SSE for push notifications only (heartbeat, cron, multi-tab sync) - const sseChannel = activeChannel === 'default' ? undefined : activeChannel - useSSE({ - url: sseChannel ? `/api/chat/events?channel=${encodeURIComponent(sseChannel)}` : '/api/chat/events', - onMessage: (data) => { - if (data.type === 'message' && data.text) { - const role = data.kind === 'message' ? 'assistant' : 'notification' - setMessages((prev) => [ - ...prev, - { kind: 'text', role, text: data.text, media: data.media, _id: nextId.current++ }, - ]) - if (userScrolledUp.current) { - setNewMsgCount((c) => c + 1) - } - } - }, - onStatus: activeChannel === 'default' ? onSSEStatus : undefined, - }) - - // Abort streaming on unmount - useEffect(() => { - return () => { abortRef.current?.abort() } - }, []) - - // Send message — streams events directly from POST response (no SSE dependency) - const handleSend = useCallback(async (text: string) => { - setStreamSegments([]) - setMessages((prev) => [...prev, { kind: 'text', role: 'user', text, _id: nextId.current++ }]) - setIsWaiting(true) - - const abort = new AbortController() - abortRef.current = abort - - try { - const channel = activeChannelRef.current === 'default' ? undefined : activeChannelRef.current - let finalText = '' - let finalMedia: Array<{ type: string; url: string }> | undefined - const segments: StreamSegment[] = [] - - for await (const event of chatApi.sendStreaming(text, channel, abort.signal)) { - if (event.type === 'stream') { - const ev = event.event - if (ev.type === 'tool_use') { - const last = segments[segments.length - 1] - if (last?.kind === 'tools') { - last.tools.push({ id: ev.id, name: ev.name, input: ev.input, status: 'running' }) - } else { - segments.push({ kind: 'tools', tools: [{ id: ev.id, name: ev.name, input: ev.input, status: 'running' }] }) - } - setStreamSegments([...segments]) - } else if (ev.type === 'tool_result') { - for (const seg of segments) { - if (seg.kind === 'tools') { - const t = seg.tools.find((tool) => tool.id === ev.tool_use_id) - if (t) { t.status = 'done'; t.result = ev.content; break } - } - } - setStreamSegments([...segments]) - } else if (ev.type === 'text') { - const last = segments[segments.length - 1] - if (last?.kind === 'text') { - last.text += ev.text - } else { - segments.push({ kind: 'text', text: ev.text }) - } - setStreamSegments([...segments]) - } - } else if (event.type === 'done') { - finalText = event.text - finalMedia = event.media?.length ? event.media : undefined - } - } - - // Stream complete — finalize messages - setStreamSegments([]) - - if (finalText) { - // Collect all tools across segments for the final tool_calls display item - const allTools = segments.flatMap((s) => s.kind === 'tools' ? s.tools : []) - setMessages((prev) => { - const next = [...prev] - if (allTools.length > 0) { - next.push({ - kind: 'tool_calls', - calls: allTools.map((t) => ({ - name: t.name, - input: typeof t.input === 'string' ? t.input : JSON.stringify(t.input ?? ''), - result: t.result, - })), - _id: nextId.current++, - }) - } - next.push({ kind: 'text', role: 'assistant', text: finalText, media: finalMedia, _id: nextId.current++ }) - return next - }) - if (userScrolledUp.current) { - setNewMsgCount((c) => c + 1) - } - } - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return - setStreamSegments([]) - const msg = err instanceof Error ? err.message : 'Unknown error' - setMessages((prev) => [ - ...prev, - { kind: 'text', role: 'notification', text: `Error: ${msg}`, _id: nextId.current++ }, - ]) - } finally { - setIsWaiting(false) - abortRef.current = null - } - }, []) + return () => { abort() } + }, [abort]) const handleScrollToBottom = useCallback(() => { userScrolledUp.current = false @@ -529,7 +393,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { )} {/* Input */} - + {/* Channel config modal */} {editingChannel && ( From 088fe36f12b53cae150e50d06bd8f82285122e15 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 19:59:30 +0800 Subject: [PATCH 10/12] feat(ui): add favicon Co-Authored-By: Claude Opus 4.6 --- ui/index.html | 1 + ui/public/alice.ico | Bin 0 -> 194494 bytes 2 files changed, 1 insertion(+) create mode 100644 ui/public/alice.ico diff --git a/ui/index.html b/ui/index.html index 25ffa4d1..96dd23d1 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,6 +3,7 @@ + Open Alice diff --git a/ui/public/alice.ico b/ui/public/alice.ico new file mode 100644 index 0000000000000000000000000000000000000000..f5d89ded1ff1c39a04677d121006ad132c6cb019 GIT binary patch literal 194494 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

F64)VHk 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

QPCC%L{W&jh+45$$Pev_O@5kYYEjgAY$NOkc zGq;EzL+5kr+M`Eht=+4{Tyl4CQqFf6`d3HDIr_D@Hev?#%e8Witknlw94j$njGq`x z45dEBu`yZJ5NJOm7m5A+&eGYzrR=vWb5#fTw9yz7*5Ra6B*lQtq!6-LY!ecF2lC%eU$M-$W|P?UBAAMGj1x!Xjbye>e>0vtBAG=%*{XmW)>kl}0 zc&<@=fM5CGb0745|K8^g`|8N$({qQCz^8IKlvKpgp^tm?({qQG&8KqtR4(tAz^85E z{SwLlk8-(u=J1?>NhuD?QI48(3lFXJc)!D_{^&XP{2^aH->*32$;!F^ai|-Q_H%zI z9*W|S@80V0=RV6lhr91LJ$EP_mCyWubB8*0Oy$0X#~kACgU<2lzTctl^VBEl+-Hlo q4+`)7@BYE({^bMi``tL#syOWL98eVR#k22|3+IY=%9sDm&i#KJp-CzL literal 0 HcmV?d00001 From 5f06c6b34e34d3cd349899d868a9a0583779b8a8 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 20:01:04 +0800 Subject: [PATCH 11/12] fix(ui): unify history and streaming message rendering - toChatHistory: emit text before tool_calls (matches streaming order) - Streaming: use ToolCallGroup for done tools (same as history), StreamingToolGroup only while running. Proper isGrouped logic so avatar/label matches history rendering. Co-Authored-By: Claude Opus 4.6 --- src/core/session.spec.ts | 10 +++++----- src/core/session.ts | 6 +++--- ui/src/pages/ChatPage.tsx | 39 +++++++++++++++++++++------------------ 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index 90bf0885..6bfe5943 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -268,12 +268,12 @@ describe('toChatHistory', () => { { type: 'tool_use', id: 't1', name: 'Search', input: { q: 'test' } }, ]), ]) - // Should produce both a tool_calls item and a text item + // Should produce text before tool_calls (边想边做) expect(items).toHaveLength(2) - expect(items[0].kind).toBe('tool_calls') - expect(items[1].kind).toBe('text') - if (items[1].kind === 'text') { - expect(items[1].text).toBe('Let me check') + expect(items[0].kind).toBe('text') + expect(items[1].kind).toBe('tool_calls') + if (items[0].kind === 'text') { + expect(items[0].text).toBe('Let me check') } }) diff --git a/src/core/session.ts b/src/core/session.ts index 688bd5d4..7eeab828 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -508,13 +508,13 @@ export function toChatHistory(entries: SessionEntry[]): ChatHistoryItem[] { result: resultMap.get(tu.id) ? truncate(resultMap.get(tu.id)!, TOOL_SUMMARY_MAX) : undefined, })) - items.push({ kind: 'tool_calls', calls, timestamp: entry.timestamp }) - - // If there were also text blocks in this same entry, emit them separately. + // Text before tools (边想边做 — thinking then doing). const text = textBlocks.map((b) => b.text).join('\n') if (text.trim() || media) { items.push({ kind: 'text', role: message.role as 'user' | 'assistant', text, timestamp: entry.timestamp, metadata: entry.metadata, media }) } + + items.push({ kind: 'tool_calls', calls, timestamp: entry.timestamp }) continue } diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index cd89206c..faf6937d 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -326,26 +326,29 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) {

0 ? 'mt-5' : ''}`}> {streamSegments.length > 0 ? ( <> -
-
- - - -
- Alice -
- {streamSegments.map((seg, i) => - seg.kind === 'tools' ? ( -
0 ? 'mt-1' : ''}> - -
- ) : ( + {streamSegments.map((seg, i) => { + if (seg.kind === 'tools') { + const allDone = seg.tools.every((t) => t.status === 'done') + return ( +
0 ? 'mt-1' : ''}> + {allDone ? ( + ({ + name: t.name, + input: typeof t.input === 'string' ? t.input : JSON.stringify(t.input ?? ''), + result: t.result, + }))} /> + ) : ( + + )} +
+ ) + } + return (
0 ? 'mt-1' : ''}> - + 0} />
- ), - )} - {/* Show thinking dots when last segment is tools with all done, or tools still running */} + ) + })} {(() => { const last = streamSegments[streamSegments.length - 1] if (last?.kind === 'tools' && last.tools.every((t) => t.status === 'done')) { From c2e4405f171920d39c3f17947e3956cd67550fc5 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 13 Mar 2026 20:02:08 +0800 Subject: [PATCH 12/12] chore: bump version to 0.9.0-beta.4 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5fdfba57..9b6d47da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-alice", - "version": "0.9.0-beta.3", + "version": "0.9.0-beta.4", "description": "File-based trading agent engine", "type": "module", "scripts": {