diff --git a/README.i18n/README.es.md b/README.i18n/README.es.md index 7d06322a9..cca5e361b 100644 --- a/README.i18n/README.es.md +++ b/README.i18n/README.es.md @@ -5,7 +5,7 @@ **La capa de mensajería para agentes.** -IM.codes es un mensajero especializado para agentes de programación con IA. Te permite seguir sesiones largas desde móvil o web, con acceso a terminal, navegación de archivos, vistas de Git, vista previa de localhost, notificaciones y flujos multiagente integrados. Funciona con [Claude Code](https://github.com/anthropics/claude-code), [Codex](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [OpenClaw](https://openclaw.com) y [Qwen](https://github.com/QwenLM/qwen-agent). +IM.codes es un mensajero especializado para agentes de programación con IA. Te permite seguir sesiones largas desde iPhone, iPad, Apple Watch, móvil o web, con acceso a terminal, navegación de archivos, vistas de Git, vista previa de localhost, notificaciones y flujos multiagente integrados. Funciona con [Claude Code](https://github.com/anthropics/claude-code), [Codex](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [OpenClaw](https://openclaw.com) y [Qwen](https://github.com/QwenLM/qwen-agent). > **Nota:** Este archivo es una traducción. **El README en inglés (`../README.md`) es la versión canónica.** Si hay alguna diferencia, prevalece la versión en inglés. diff --git a/README.i18n/README.ja.md b/README.i18n/README.ja.md index ddfff26ac..85ea11536 100644 --- a/README.i18n/README.ja.md +++ b/README.i18n/README.ja.md @@ -4,7 +4,7 @@ **AI エージェントのための IM。** -IM.codes は AI コーディングエージェント向けの専用メッセンジャーです。モバイルや Web から長時間動作する agent session にアクセスし、ターミナル、ファイル閲覧、Git 変更、localhost プレビュー、通知、マルチエージェント連携を扱えます。Claude Code、Codex、Gemini CLI、OpenClaw、Qwen に対応します。 +IM.codes は AI コーディングエージェント向けの専用メッセンジャーです。iPhone、iPad、Apple Watch、モバイルや Web から長時間動作する agent session にアクセスし、ターミナル、ファイル閲覧、Git 変更、localhost プレビュー、通知、マルチエージェント連携を扱えます。Claude Code、Codex、Gemini CLI、OpenClaw、Qwen に対応します。 > これは翻訳版です。**正式な内容は英語版 README(`../README.md`)です。** 差異がある場合は英語版を優先してください。 diff --git a/README.i18n/README.ko.md b/README.i18n/README.ko.md index 146c1ae0e..171c5a644 100644 --- a/README.i18n/README.ko.md +++ b/README.i18n/README.ko.md @@ -4,7 +4,7 @@ **AI 에이전트를 위한 IM.** -IM.codes는 AI 코딩 에이전트를 위한 전용 메신저입니다. 모바일이나 웹에서 장시간 실행 중인 agent session에 접근해 터미널, 파일 브라우징, Git 변경 보기, localhost 미리보기, 알림, 멀티 에이전트 워크플로를 사용할 수 있습니다. Claude Code, Codex, Gemini CLI, OpenClaw, Qwen을 지원합니다. +IM.codes는 AI 코딩 에이전트를 위한 전용 메신저입니다. iPhone, iPad, Apple Watch, 모바일이나 웹에서 장시간 실행 중인 agent session에 접근해 터미널, 파일 브라우징, Git 변경 보기, localhost 미리보기, 알림, 멀티 에이전트 워크플로를 사용할 수 있습니다. Claude Code, Codex, Gemini CLI, OpenClaw, Qwen을 지원합니다. > 이 문서는 번역본입니다. **기준 문서는 영어 README(`../README.md`)입니다.** 차이가 있으면 영어판을 우선합니다. diff --git a/README.i18n/README.ru.md b/README.i18n/README.ru.md index a6b021b6c..cf74897ed 100644 --- a/README.i18n/README.ru.md +++ b/README.i18n/README.ru.md @@ -4,7 +4,7 @@ **Слой мессенджера для агентов.** -IM.codes — специализированный мессенджер для AI coding agents. Он позволяет держать долгие agent‑сессии под рукой с телефона или из веба: терминал, файлы, Git, просмотр localhost, уведомления и multi‑agent workflows. Поддерживаются Claude Code, Codex, Gemini CLI, OpenClaw и Qwen. +IM.codes — специализированный мессенджер для AI coding agents. Он позволяет держать долгие agent‑сессии под рукой с iPhone, iPad, Apple Watch, телефона или из веба: терминал, файлы, Git, просмотр localhost, уведомления и multi‑agent workflows. Поддерживаются Claude Code, Codex, Gemini CLI, OpenClaw и Qwen. > Это перевод. **Каноническая версия — английский README (`../README.md`).** Если есть расхождения, ориентируйтесь на английский вариант. diff --git a/README.i18n/README.zh-CN.md b/README.i18n/README.zh-CN.md index d910728de..9da755568 100644 --- a/README.i18n/README.zh-CN.md +++ b/README.i18n/README.zh-CN.md @@ -5,7 +5,7 @@ **Agent 的即时通讯层。** -IM.codes 是一个面向 AI 编码代理的专用即时通讯器。你可以在手机或网页上持续查看长时间运行的 agent 会话,直接访问终端、浏览文件、查看 Git 变更、预览本地 localhost、接收通知,并进行多 agent 协作。支持 [Claude Code](https://github.com/anthropics/claude-code)、[Codex](https://github.com/openai/codex)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[OpenClaw](https://openclaw.com)、[Qwen](https://github.com/QwenLM/qwen-agent) 等,也支持 transport 型 agent 的原生流式输出。 +IM.codes 是一个面向 AI 编码代理的专用即时通讯器。你可以在 iPhone、iPad、Apple Watch、手机或网页上持续查看长时间运行的 agent 会话,直接访问终端、浏览文件、查看 Git 变更、预览本地 localhost、接收通知,并进行多 agent 协作。支持 [Claude Code](https://github.com/anthropics/claude-code)、[Codex](https://github.com/openai/codex)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[OpenClaw](https://openclaw.com)、[Qwen](https://github.com/QwenLM/qwen-agent) 等,也支持 transport 型 agent 的原生流式输出。 > **说明:** 本文件是中文翻译版。**英文 README(`../README.md`)是规范版本。** 若内容存在差异,以英文版为准。 diff --git a/README.i18n/README.zh-TW.md b/README.i18n/README.zh-TW.md index 460c6c156..e015509b0 100644 --- a/README.i18n/README.zh-TW.md +++ b/README.i18n/README.zh-TW.md @@ -5,7 +5,7 @@ **Agent 的即時通訊層。** -IM.codes 是一个面向 AI 编码代理的專用即時通訊器。你可以在手機或網頁上持续檢視长时间运行的 agent 会话,直接访问终端、瀏覽文件、檢視 Git 變更、預覽本地 localhost、接收通知,并进行多 agent 协作。支持 [Claude Code](https://github.com/anthropics/claude-code)、[Codex](https://github.com/openai/codex)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[OpenClaw](https://openclaw.com)、[Qwen](https://github.com/QwenLM/qwen-agent) 等,也支持 transport 型 agent 的原生流式输出。 +IM.codes 是一个面向 AI 编码代理的專用即時通訊器。你可以在 iPhone、iPad、Apple Watch、手機或網頁上持续檢視长时间运行的 agent 会话,直接访问终端、瀏覽文件、檢視 Git 變更、預覽本地 localhost、接收通知,并进行多 agent 协作。支持 [Claude Code](https://github.com/anthropics/claude-code)、[Codex](https://github.com/openai/codex)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[OpenClaw](https://openclaw.com)、[Qwen](https://github.com/QwenLM/qwen-agent) 等,也支持 transport 型 agent 的原生流式输出。 > **說明:** 本文件是中文翻译版。**英文 README(`../README.md`)是規範版本。** 若内容存在差异,以英文版为准。 diff --git a/README.md b/README.md index fa157e2a2..9bae39fa0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **The IM for agents.** -A specialized instant messenger for AI agents. Keep long-running coding-agent sessions within reach from mobile or web, with terminal access, file browsing, git views, localhost preview, notifications, and multi-agent workflows built in. Works with [Claude Code](https://github.com/anthropics/claude-code) and [Codex](https://github.com/openai/codex) via both CLI and SDK integrations, plus [Gemini CLI](https://github.com/google-gemini/gemini-cli), [OpenClaw](https://openclaw.com), [Qwen](https://github.com/QwenLM/qwen-agent), and more — including native streaming output for transport-backed agents. +A specialized instant messenger for AI agents. Keep long-running coding-agent sessions within reach from iPhone, iPad, Apple Watch, mobile, or web, with terminal access, file browsing, git views, localhost preview, notifications, and multi-agent workflows built in. Works with [Claude Code](https://github.com/anthropics/claude-code) and [Codex](https://github.com/openai/codex) via both CLI and SDK integrations, plus [Gemini CLI](https://github.com/google-gemini/gemini-cli), [OpenClaw](https://openclaw.com), [Qwen](https://github.com/QwenLM/qwen-agent), and more — including native streaming output for transport-backed agents. > **Disclaimer:** This is an actively developed personal open-source project. There are no warranties, no SLA, and no guarantees of stability, security, or backward compatibility. Use at your own risk. Breaking changes may happen at any time without notice. diff --git a/landing/index.html b/landing/index.html index ce668302c..e0c0b3e76 100644 --- a/landing/index.html +++ b/landing/index.html @@ -236,7 +236,7 @@

IM.codes

-

Keep long-running coding-agent sessions within reach from mobile or web, with terminal access, file browsing, git views, localhost preview, notifications, and multi-agent workflows built in.

+

Keep long-running coding-agent sessions within reach from iPhone, iPad, Apple Watch, mobile, or web, with terminal access, file browsing, git views, localhost preview, notifications, and multi-agent workflows built in.

imcodes bind https://app.im.codes/bind/<key>
bound to app.im.codes · daemon started · registered as system service
@@ -468,7 +468,7 @@

about

}, 'zh-CN': { tagline: '为 AI 代理而生的即时通讯', - hero_intro: '让长时间运行的 coding agent 会话始终触手可及:手机或网页即可查看终端、文件、Git、localhost 预览、通知和多代理工作流。', + hero_intro: '让长时间运行的 coding agent 会话始终触手可及:iPhone、iPad、Apple Watch、手机或网页即可查看终端、文件、Git、localhost 预览、通知和多代理工作流。', hero_output: '已绑定 app.im.codes · 守护进程已启动 · 已注册为系统服务', self_host_warning: '强烈建议自行部署。app.im.codes 是共享测试实例,无可用性保证,可能被限流、攻击或不可用。这是个人项目,不提供商用保障。正式使用请部署到自己的服务器。', h_screenshots: '截图', h_why: '为什么', h_not: '它不是什么', h_features: '功能', h_arch: '架构', h_download: '下载', h_install: '安装', h_quick: '快速开始', h_selfhost: '自托管部署', h_agents: '支持的代理', h_reqs: '系统要求', h_about: '关于', @@ -527,7 +527,7 @@

about

}, 'zh-TW': { tagline: '為 AI 代理而生的即時通訊', - hero_intro: '讓長時間運行的 coding agent 會話始終觸手可及:手機或網頁即可查看終端、檔案、Git、localhost 預覽、通知和多代理工作流。', + hero_intro: '讓長時間運行的 coding agent 會話始終觸手可及:iPhone、iPad、Apple Watch、手機或網頁即可查看終端、檔案、Git、localhost 預覽、通知和多代理工作流。', hero_output: '已綁定 app.im.codes · 守護程序已啟動 · 已註冊為系統服務', self_host_warning: '強烈建議自行部署。app.im.codes 是共享測試實例,無可用性保證,可能被限流、攻擊或不可用。這是個人專案,不提供商用保障。正式使用請部署到自己的伺服器。', h_screenshots: '截圖', h_why: '為什麼', h_not: '它不是什麼', h_features: '功能', h_arch: '架構', h_download: '下載', h_install: '安裝', h_quick: '快速開始', h_selfhost: '自託管部署', h_agents: '支援的代理', h_reqs: '系統需求', h_about: '關於', @@ -586,7 +586,7 @@

about

}, ja: { tagline: 'AIエージェントのためのIM', - hero_intro: '長時間動く coding agent セッションを、モバイルやWebから常に手の届く場所に。ターミナル、ファイル、Git、localhost プレビュー、通知、マルチエージェントワークフローをまとめて提供します。', + hero_intro: '長時間動く coding agent セッションを、iPhone、iPad、Apple Watch、モバイルやWebから常に手の届く場所に。ターミナル、ファイル、Git、localhost プレビュー、通知、マルチエージェントワークフローをまとめて提供します。', hero_output: 'app.im.codes にバインド完了 · デーモン起動 · システムサービスとして登録', self_host_warning: 'セルフホスティングを強く推奨します。app.im.codes は共有テストインスタンスであり、稼働保証はありません。レート制限、攻撃対象、利用不可の可能性があります。個人プロジェクトのため商用サポートはありません。評価以外の用途では自社インフラにデプロイしてください。', h_screenshots: 'スクリーンショット', h_why: '背景', h_not: 'これは何ではないか', h_features: '機能', h_arch: 'アーキテクチャ', h_download: 'ダウンロード', h_install: 'インストール', h_quick: 'クイックスタート', h_selfhost: 'セルフホスト', h_agents: '対応エージェント', h_reqs: '要件', h_about: '概要', @@ -644,7 +644,7 @@

about

}, ko: { tagline: 'AI 에이전트를 위한 IM', - hero_intro: '오래 실행되는 coding agent 세션을 모바일이나 웹에서 항상 닿는 곳에 두세요. 터미널, 파일, Git, localhost 미리보기, 알림, 멀티 에이전트 워크플로우가 함께 제공됩니다.', + hero_intro: '오래 실행되는 coding agent 세션을 iPhone, iPad, Apple Watch, 모바일이나 웹에서 항상 닿는 곳에 두세요. 터미널, 파일, Git, localhost 미리보기, 알림, 멀티 에이전트 워크플로우가 함께 제공됩니다.', hero_output: 'app.im.codes에 바인딩 완료 · 데몬 시작됨 · 시스템 서비스로 등록됨', self_host_warning: '셀프 호스팅을 강력히 권장합니다. app.im.codes는 공유 테스트 인스턴스로 가동 보장이 없으며, 속도 제한, 공격 대상이 되거나 사용 불가할 수 있습니다. 개인 프로젝트로 상업적 지원은 제공되지 않습니다. 평가 이외의 용도에는 자체 인프라에 배포하세요.', h_screenshots: '스크린샷', h_why: '배경', h_not: '무엇이 아닌가', h_features: '기능', h_arch: '아키텍처', h_download: '다운로드', h_install: '설치', h_quick: '빠른 시작', h_selfhost: '셀프 호스팅', h_agents: '지원 에이전트', h_reqs: '요구사항', h_about: '소개', @@ -703,7 +703,7 @@

about

}, es: { tagline: 'El IM para agentes', - hero_intro: 'Mantén las sesiones de coding agents de larga duración al alcance desde móvil o web, con terminal, archivos, vistas Git, vista previa de localhost, notificaciones y flujos multiagente integrados.', + hero_intro: 'Mantén las sesiones de coding agents de larga duración al alcance desde iPhone, iPad, Apple Watch, móvil o web, con terminal, archivos, vistas Git, vista previa de localhost, notificaciones y flujos multiagente integrados.', hero_output: 'vinculado a app.im.codes · daemon iniciado · registrado como servicio del sistema', self_host_warning: 'Se recomienda encarecidamente el autoalojamiento. app.im.codes es una instancia de prueba compartida sin garantías de disponibilidad — puede tener límites, ser objetivo de ataques o no estar disponible. Este es un proyecto personal sin soporte comercial. Para uso más allá de la evaluación, despliega en tu propia infraestructura.', h_screenshots: 'capturas', h_why: 'por qué', h_not: 'qué no es', h_features: 'características', h_arch: 'arquitectura', h_download: 'descargar', h_install: 'instalar', h_quick: 'inicio rápido', h_selfhost: 'autoalojamiento', h_agents: 'agentes compatibles', h_reqs: 'requisitos', h_about: 'acerca de', @@ -762,7 +762,7 @@

about

}, ru: { tagline: 'IM для агентов', - hero_intro: 'Держите долгоживущие coding agent-сессии под рукой с телефона или из браузера: терминал, файлы, Git, localhost-превью, уведомления и мульти-агентные сценарии уже встроены.', + hero_intro: 'Держите долгоживущие coding agent-сессии под рукой с iPhone, iPad, Apple Watch, телефона или из браузера: терминал, файлы, Git, localhost-превью, уведомления и мульти-агентные сценарии уже встроены.', hero_output: 'привязан к app.im.codes · демон запущен · зарегистрирован как системная служба', self_host_warning: 'Настоятельно рекомендуется самостоятельный хостинг. app.im.codes — общий тестовый экземпляр без гарантий доступности. Может быть ограничен, атакован или недоступен. Это личный проект без коммерческой поддержки. Для использования помимо тестирования разверните на собственной инфраструктуре.', h_screenshots: 'скриншоты', h_why: 'зачем', h_not: 'чем это не является', h_features: 'возможности', h_arch: 'архитектура', h_download: 'скачать', h_install: 'установка', h_quick: 'быстрый старт', h_selfhost: 'свой сервер', h_agents: 'поддерживаемые агенты', h_reqs: 'требования', h_about: 'о проекте', diff --git a/server/src/db/queries.ts b/server/src/db/queries.ts index e5347a609..109cc408b 100644 --- a/server/src/db/queries.ts +++ b/server/src/db/queries.ts @@ -368,6 +368,7 @@ export async function upsertDbSession( agentType: string, projectDir: string, state: string, + label?: string | null, agentVersion?: string | null, runtimeType?: string | null, providerId?: string | null, @@ -380,14 +381,15 @@ export async function upsertDbSession( ): Promise { const now = Date.now(); await db.execute( - `INSERT INTO sessions (id, server_id, name, project_name, role, agent_type, agent_version, project_dir, state, runtime_type, provider_id, provider_session_id, description, requested_model, active_model, effort, transport_config, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::jsonb, $18, $19) + `INSERT INTO sessions (id, server_id, name, project_name, role, agent_type, agent_version, project_dir, state, label, runtime_type, provider_id, provider_session_id, description, requested_model, active_model, effort, transport_config, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19, $20) ON CONFLICT(server_id, name) DO UPDATE SET role = excluded.role, agent_type = excluded.agent_type, agent_version = excluded.agent_version, project_dir = excluded.project_dir, state = excluded.state, + label = COALESCE(excluded.label, sessions.label), runtime_type = excluded.runtime_type, provider_id = excluded.provider_id, provider_session_id = excluded.provider_session_id, @@ -407,6 +409,7 @@ export async function upsertDbSession( agentVersion ?? null, projectDir, state, + label ?? null, runtimeType ?? null, providerId ?? null, providerSessionId ?? null, diff --git a/server/src/routes/session-mgmt.ts b/server/src/routes/session-mgmt.ts index 6489ca13e..19a548e99 100644 --- a/server/src/routes/session-mgmt.ts +++ b/server/src/routes/session-mgmt.ts @@ -63,6 +63,7 @@ sessionMgmtRoutes.put('/:id/sessions/:name', async (c) => { agentVersion, projectDir, state, + label, runtimeType, providerId, providerSessionId, @@ -86,6 +87,7 @@ sessionMgmtRoutes.put('/:id/sessions/:name', async (c) => { String(agentType), String(projectDir), String(state), + typeof label === 'string' && label.trim() ? label.trim() : null, typeof agentVersion === 'string' ? agentVersion : null, typeof runtimeType === 'string' ? runtimeType : null, typeof providerId === 'string' ? providerId : null, diff --git a/server/test/db.integration.test.ts b/server/test/db.integration.test.ts index ea65d27f4..2f5eefa2f 100644 --- a/server/test/db.integration.test.ts +++ b/server/test/db.integration.test.ts @@ -474,6 +474,15 @@ describe('sessions', () => { expect(s?.state).toBe('idle'); }); + it('upsertDbSession preserves an existing label when a later sync omits it', async () => { + await upsertDbSession(db, 'sid-keep-label', serverId, 'deck_proj_brain', 'myproj', 'brain', 'claude-code', '/home/dev', 'idle', 'Readable Main'); + await upsertDbSession(db, 'sid-1', serverId, 'deck_proj_brain', 'myproj', 'brain', 'claude-code', '/home/dev', 'running'); + const sessions = await getDbSessionsByServer(db, serverId); + const s = sessions.find((session) => session.name === 'deck_proj_brain'); + expect(s?.label).toBe('Readable Main'); + expect(s?.state).toBe('running'); + }); + it('updateSessionLabel sets label', async () => { await updateSessionLabel(db, serverId, 'deck_proj_brain', 'My Project'); const sessions = await getDbSessionsByServer(db, serverId); @@ -968,7 +977,7 @@ describe('transport session metadata persistence', () => { it('upsertDbSession with transport fields roundtrip', async () => { await upsertDbSession( db, 'tmd-sid-1', serverId, 'deck_transport_brain', 'tproj', 'brain', 'claude-code', '/home/dev', - 'running', null, 'transport', 'openclaw', 'oc-key-123', 'test persona', + 'running', null, null, 'transport', 'openclaw', 'oc-key-123', 'test persona', ); const sessions = await getDbSessionsByServer(db, serverId); const s = sessions.find(s => s.name === 'deck_transport_brain'); @@ -983,7 +992,7 @@ describe('transport session metadata persistence', () => { // Upsert same session with a new state — transport fields should survive await upsertDbSession( db, 'tmd-sid-1', serverId, 'deck_transport_brain', 'tproj', 'brain', 'claude-code', '/home/dev', - 'idle', null, 'transport', 'openclaw', 'oc-key-123', 'test persona', 'sonnet', 'sonnet', 'high', { provider: { mode: 'safe' } }, + 'idle', null, null, 'transport', 'openclaw', 'oc-key-123', 'test persona', 'sonnet', 'sonnet', 'high', { provider: { mode: 'safe' } }, ); const sessions = await getDbSessionsByServer(db, serverId); const s = sessions.find(s => s.name === 'deck_transport_brain'); diff --git a/server/test/session-mgmt-routes.test.ts b/server/test/session-mgmt-routes.test.ts index 9daa8cb40..aa3ea97a6 100644 --- a/server/test/session-mgmt-routes.test.ts +++ b/server/test/session-mgmt-routes.test.ts @@ -58,7 +58,7 @@ describe('session-mgmt persistence routes', () => { return app; } - it('PUT /sessions/:name persists requestedModel/activeModel/effort/transportConfig', async () => { + it('PUT /sessions/:name persists label plus requestedModel/activeModel/effort/transportConfig', async () => { const app = await buildApp(); const res = await app.request('/api/server/srv-1/sessions/deck_proj_brain', { method: 'PUT', @@ -69,6 +69,7 @@ describe('session-mgmt persistence routes', () => { agentType: 'claude-code-sdk', projectDir: '/tmp/proj', state: 'idle', + label: 'Readable Main', runtimeType: 'transport', providerId: 'claude-code-sdk', providerSessionId: 'route-1', @@ -91,6 +92,7 @@ describe('session-mgmt persistence routes', () => { 'claude-code-sdk', '/tmp/proj', 'idle', + 'Readable Main', null, 'transport', 'claude-code-sdk', @@ -198,4 +200,22 @@ describe('session-mgmt persistence routes', () => { label: 'Main Label', }); }); + + it('PATCH /sessions/:name/label allows clearing the label and still relays session.relabel', async () => { + const { updateSessionLabel } = await import('../src/db/queries.js'); + const app = await buildApp(); + const res = await app.request('/api/server/srv-1/sessions/deck_proj_brain/label', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: '' }), + }); + + expect(res.status).toBe(200); + expect(updateSessionLabel).toHaveBeenCalledWith({}, 'srv-1', 'deck_proj_brain', null); + expect(JSON.parse(String(sendToDaemonMock.mock.calls[0]?.[0]))).toEqual({ + type: 'session.relabel', + sessionName: 'deck_proj_brain', + label: null, + }); + }); }); diff --git a/src/agent/providers/claude-code-sdk.ts b/src/agent/providers/claude-code-sdk.ts index a85a98b69..6f0f88ebb 100644 --- a/src/agent/providers/claude-code-sdk.ts +++ b/src/agent/providers/claude-code-sdk.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { access } from 'node:fs/promises'; import { constants as fsConstants } from 'node:fs'; -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { query, type PermissionMode, type SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import type { TransportProvider, @@ -25,6 +25,8 @@ import { normalizeTransportCwd, resolveExecutableForSpawn } from '../transport-p const CLAUDE_BIN = 'claude'; const DEFAULT_PERMISSION_MODE: PermissionMode = 'bypassPermissions'; +const CANCEL_INTERRUPT_TIMEOUT_MS = 1_500; +const FORCE_KILL_TIMEOUT_MS = 500; interface ClaudeSdkSessionState { routeId: string; @@ -41,9 +43,11 @@ interface ClaudeSdkSessionState { currentMessageId: string | null; currentText: string; currentQuery: ReturnType | null; + currentChild: ChildProcess | null; completed: boolean; cancelled: boolean; finalMetadata?: Record; + lastAssistantUsage?: ClaudeUsageSnapshot; pendingComplete?: AgentMessage; pendingError?: ProviderError; toolCalls: Map; @@ -51,6 +55,13 @@ interface ClaudeSdkSessionState { lastStatusSignature: string | null; } +interface ClaudeUsageSnapshot { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; +} + type ClaudeToolBlock = { type: 'tool_use' | 'server_tool_use' | 'mcp_tool_use'; id?: string; @@ -113,6 +124,7 @@ export class ClaudeCodeSdkProvider implements TransportProvider { async disconnect(): Promise { for (const state of this.sessions.values()) { try { state.currentQuery?.close(); } catch {} + this.terminateChild(state); } this.sessions.clear(); this.config = null; @@ -137,9 +149,11 @@ export class ClaudeCodeSdkProvider implements TransportProvider { currentMessageId: existing?.currentMessageId ?? null, currentText: existing?.currentText ?? '', currentQuery: null, + currentChild: null, completed: false, cancelled: false, finalMetadata: existing?.finalMetadata, + lastAssistantUsage: existing?.lastAssistantUsage, pendingComplete: undefined, toolCalls: new Map(), emittedToolStates: new Map(), @@ -153,6 +167,7 @@ export class ClaudeCodeSdkProvider implements TransportProvider { const state = this.sessions.get(sessionId); if (state) { try { state.currentQuery?.close(); } catch {} + this.terminateChild(state); this.sessions.delete(sessionId); } } @@ -234,11 +249,15 @@ export class ClaudeCodeSdkProvider implements TransportProvider { if (!state?.currentQuery) return; state.cancelled = true; try { - await state.currentQuery.interrupt(); + await Promise.race([ + state.currentQuery.interrupt(), + new Promise((resolve) => setTimeout(resolve, CANCEL_INTERRUPT_TIMEOUT_MS)), + ]); } catch {} try { state.currentQuery.close(); } catch {} + this.terminateChild(state); } private async startQuery( @@ -253,6 +272,7 @@ export class ClaudeCodeSdkProvider implements TransportProvider { state.completed = false; state.cancelled = false; state.finalMetadata = undefined; + state.lastAssistantUsage = undefined; state.pendingComplete = undefined; state.pendingError = undefined; state.toolCalls.clear(); @@ -275,30 +295,27 @@ export class ClaudeCodeSdkProvider implements TransportProvider { appendSystemPrompt: [baseSystemPrompt, state.systemPrompt].filter(Boolean).join('\n\n'), } : {}), }; - // On Windows where claude resolved to a .cmd shim, override the SDK's - // internal spawn so we can prepend `node script.js` and avoid spawn - // EINVAL on .cmd files. - if (this.windowsSpawnOverride) { - const override = this.windowsSpawnOverride; - options.spawnClaudeCodeProcess = (req: { command: string; args: string[]; cwd?: string; env?: Record; signal?: AbortSignal }) => { - const finalArgs = [...override.prependArgs, ...req.args]; - const child = spawn(override.executable, finalArgs, { - cwd: req.cwd, - env: req.env, - signal: req.signal, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - // CRITICAL: always listen for 'error' so spawn failures don't bubble - // up to uncaughtException and crash the daemon. The SDK will also - // observe the error via the child's exit/stderr, but if it doesn't - // we still need to swallow it here. - child.on('error', (err) => { - logger.error({ provider: this.id, err }, 'Claude SDK spawn error (suppressed)'); - }); - return child; - }; - } + options.spawnClaudeCodeProcess = (req: { command: string; args: string[]; cwd?: string; env?: Record; signal?: AbortSignal }) => { + const finalCommand = this.windowsSpawnOverride?.executable ?? req.command; + const finalArgs = this.windowsSpawnOverride + ? [...this.windowsSpawnOverride.prependArgs, ...req.args] + : req.args; + const child = spawn(finalCommand, finalArgs, { + cwd: req.cwd, + env: req.env, + signal: req.signal, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + state.currentChild = child; + child.once('exit', () => { + if (state.currentChild === child) state.currentChild = null; + }); + child.on('error', (err) => { + logger.error({ provider: this.id, err }, 'Claude SDK spawn error (suppressed)'); + }); + return child; + }; const q = query({ prompt: message, options: options as any }); state.currentQuery = q; @@ -330,6 +347,7 @@ export class ClaudeCodeSdkProvider implements TransportProvider { : this.normalizeError(err); } finally { state.currentQuery = null; + state.currentChild = null; const pendingComplete = state.pendingComplete; state.pendingComplete = undefined; state.pendingError = undefined; @@ -446,6 +464,15 @@ export class ClaudeCodeSdkProvider implements TransportProvider { } if (msg.type === 'assistant') { + const assistantUsage = msg.message?.usage as ClaudeUsageSnapshot | undefined; + if (assistantUsage && typeof assistantUsage === 'object') { + state.lastAssistantUsage = { + ...(typeof assistantUsage.input_tokens === 'number' ? { input_tokens: assistantUsage.input_tokens } : {}), + ...(typeof assistantUsage.output_tokens === 'number' ? { output_tokens: assistantUsage.output_tokens } : {}), + ...(typeof assistantUsage.cache_read_input_tokens === 'number' ? { cache_read_input_tokens: assistantUsage.cache_read_input_tokens } : {}), + ...(typeof assistantUsage.cache_creation_input_tokens === 'number' ? { cache_creation_input_tokens: assistantUsage.cache_creation_input_tokens } : {}), + }; + } const text = collectAssistantText(msg); if (Array.isArray(msg.message.content)) { for (const block of msg.message.content) { @@ -527,7 +554,8 @@ export class ClaudeCodeSdkProvider implements TransportProvider { status: 'complete', metadata: { ...(state.model ? { model: state.model } : {}), - usage: msg.usage, + usage: state.lastAssistantUsage ?? msg.usage, + ...(state.lastAssistantUsage && state.lastAssistantUsage !== msg.usage ? { totalUsage: msg.usage } : {}), resultSubtype: msg.subtype, resumeId: state.resumeId, }, @@ -639,4 +667,15 @@ export class ClaudeCodeSdkProvider implements TransportProvider { private makeError(code: string, message: string, recoverable: boolean, details?: unknown): ProviderError { return { code, message, recoverable, ...(details !== undefined ? { details } : {}) }; } + + private terminateChild(state: ClaudeSdkSessionState): void { + const child = state.currentChild; + if (!child || child.killed) return; + try { child.kill('SIGTERM'); } catch {} + const timer = setTimeout(() => { + if (state.currentChild !== child || child.killed) return; + try { child.kill('SIGKILL'); } catch {} + }, FORCE_KILL_TIMEOUT_MS); + timer.unref?.(); + } } diff --git a/src/agent/providers/codex-sdk.ts b/src/agent/providers/codex-sdk.ts index 4a936474b..f2ab43c2f 100644 --- a/src/agent/providers/codex-sdk.ts +++ b/src/agent/providers/codex-sdk.ts @@ -23,6 +23,7 @@ import { CODEX_SDK_EFFORT_LEVELS, type TransportEffortLevel } from '../../../sha import { normalizeTransportCwd, resolveExecutableForSpawn } from '../transport-paths.js'; const CODEX_BIN = 'codex'; +const CANCEL_INTERRUPT_TIMEOUT_MS = 1_500; type JsonRpcResponse = { id?: number; @@ -49,6 +50,7 @@ interface CodexSdkSessionState { currentText: string; pendingComplete?: AgentMessage; cancelled: boolean; + cancelTimer: ReturnType | null; lastUsage?: { input_tokens: number; cached_input_tokens: number; @@ -223,6 +225,7 @@ export class CodexSdkProvider implements TransportProvider { currentText: '', pendingComplete: undefined, cancelled: false, + cancelTimer: null, lastUsage: undefined, lastStatusSignature: null, }); @@ -233,6 +236,7 @@ export class CodexSdkProvider implements TransportProvider { async endSession(sessionId: string): Promise { const state = this.sessions.get(sessionId); if (!state) return; + this.clearCancelTimer(state); if (state.threadId && state.loaded) { await this.request('thread/unsubscribe', { threadId: state.threadId }).catch(() => {}); this.threadToSession.delete(state.threadId); @@ -313,6 +317,7 @@ export class CodexSdkProvider implements TransportProvider { state.currentMessageId = null; state.pendingComplete = undefined; state.cancelled = false; + this.clearCancelTimer(state); state.lastUsage = undefined; state.lastStatusSignature = null; await this.startTurn(sessionId, state, message); @@ -322,10 +327,21 @@ export class CodexSdkProvider implements TransportProvider { const state = this.sessions.get(sessionId); if (!state?.threadId || !state.runningTurnId) return; state.cancelled = true; + const turnId = state.runningTurnId; await this.request('turn/interrupt', { threadId: state.threadId, - turnId: state.runningTurnId, + turnId, }).catch(() => {}); + this.clearCancelTimer(state); + state.cancelTimer = setTimeout(() => { + if (!this.sessions.has(sessionId)) return; + if (state.runningTurnId !== turnId) return; + this.clearStatus(sessionId, state); + state.runningTurnId = undefined; + state.pendingComplete = undefined; + this.emitError(sessionId, this.makeError(PROVIDER_ERROR_CODES.CANCELLED, 'Codex turn cancelled', true)); + }, CANCEL_INTERRUPT_TIMEOUT_MS); + state.cancelTimer.unref?.(); } private async startAppServer(binaryPath: string): Promise { @@ -546,18 +562,25 @@ export class CodexSdkProvider implements TransportProvider { const status = turn.status; if (status === 'failed') { + this.clearCancelTimer(state); this.clearStatus(sessionId, state); state.runningTurnId = undefined; this.emitError(sessionId, this.makeError(PROVIDER_ERROR_CODES.PROVIDER_ERROR, turn.error?.message ?? 'Codex turn failed', false, turn.error)); return; } if (status === 'interrupted') { + this.clearCancelTimer(state); + if (!state.runningTurnId && state.cancelled) { + state.cancelled = false; + return; + } this.clearStatus(sessionId, state); state.runningTurnId = undefined; this.emitError(sessionId, this.makeError(PROVIDER_ERROR_CODES.CANCELLED, 'Codex turn cancelled', true)); return; } + this.clearCancelTimer(state); this.clearStatus(sessionId, state); state.pendingComplete = { id: state.currentMessageId ?? `${sessionId}:agent-message`, @@ -645,4 +668,10 @@ export class CodexSdkProvider implements TransportProvider { private makeError(code: string, message: string, recoverable: boolean, details?: unknown): ProviderError { return { code, message, recoverable, ...(details !== undefined ? { details } : {}) }; } + + private clearCancelTimer(state: CodexSdkSessionState): void { + if (!state.cancelTimer) return; + clearTimeout(state.cancelTimer); + state.cancelTimer = null; + } } diff --git a/src/agent/session-manager.ts b/src/agent/session-manager.ts index a9116d69e..c4908808a 100644 --- a/src/agent/session-manager.ts +++ b/src/agent/session-manager.ts @@ -36,6 +36,7 @@ import { getClaudeSdkRuntimeConfig } from './sdk-runtime-config.js'; import { getCodexRuntimeConfig } from './codex-runtime-config.js'; import type { TransportEffortLevel } from '../../shared/effort-levels.js'; import { isClaudeCodeFamily, isCodexFamily } from '../../shared/agent-types.js'; +import { providerQuotaMetaEquals } from '../../shared/provider-quota.js'; import { getAgentVersion } from './agent-version.js'; import { repoCache } from '../repo/cache.js'; @@ -816,9 +817,10 @@ function stopStructuredWatchers(sessionName: string): void { export async function stopTransportRuntimeSession(sessionName: string): Promise { const transportRuntime = transportRuntimes.get(sessionName); if (!transportRuntime) return; - if (transportRuntime.providerSessionId) unregisterProviderRoute(transportRuntime.providerSessionId); - await transportRuntime.kill(); + const providerSid = transportRuntime.providerSessionId; transportRuntimes.delete(sessionName); + if (providerSid) unregisterProviderRoute(providerSid); + await transportRuntime.kill(); } async function teardownSessionRuntime(record: SessionRecord): Promise { @@ -846,6 +848,11 @@ export async function relaunchSessionWithSettings( const compatibleIds = getCompatibleSessionIds(record, targetAgentType); const preserveTransportBinding = record.runtimeType === RUNTIME_TYPES.TRANSPORT && record.agentType === targetAgentType + // Qwen uses providerSessionId as its real resume key, so explicit restart must + // preserve it. Claude/Codex SDKs keep their provider continuity in ccSessionId / + // codexSessionId and therefore use a fresh local route key on relaunch. + && targetAgentType !== 'claude-code-sdk' + && targetAgentType !== 'codex-sdk' && typeof record.providerSessionId === 'string' && record.providerSessionId.length > 0; @@ -952,6 +959,11 @@ function wireTransportSessionInfo(runtime: TransportSessionRuntime, sessionName: changed = true; } + if (info.quotaMeta !== undefined && !providerQuotaMetaEquals(next.quotaMeta, info.quotaMeta)) { + next.quotaMeta = info.quotaMeta; + changed = true; + } + if (typeof info.effort === 'string' && next.effort !== info.effort) { next.effort = info.effort; changed = true; @@ -1034,7 +1046,8 @@ export async function restoreTransportSessions(providerId: string): Promise { const existingRuntime = transportRuntimes.get(name); if (existingRuntime) { const oldProviderSid = existingRuntime.providerSessionId; + transportRuntimes.delete(name); + if (oldProviderSid) unregisterProviderRoute(oldProviderSid); try { await existingRuntime.kill(); } catch (err) { logger.warn({ err, session: name }, 'Failed to kill existing transport runtime before fresh launch'); } - transportRuntimes.delete(name); - if (oldProviderSid) unregisterProviderRoute(oldProviderSid); } } @@ -1144,7 +1159,7 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { let qwenAuthType: SessionRecord['qwenAuthType'] | undefined; let qwenAuthLimit: SessionRecord['qwenAuthLimit'] | undefined; let availableQwenModels: string[] | undefined; - let sdkDisplay: Pick | undefined; + let sdkDisplay: Pick | undefined; let transportSystemPrompt: string | undefined; let transportSettings: string | Record | undefined; const storedRequestedModel = !opts.fresh ? existing?.requestedModel : undefined; @@ -1182,7 +1197,12 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { effectiveSkipCreate = false; } } else if (agentType === 'claude-code-sdk') { + effectiveSessionKey = randomUUID(); + effectiveBindExistingKey = undefined; transportResumeId = opts.ccSessionId ?? (!opts.fresh ? getSession(name)?.ccSessionId : undefined) ?? randomUUID(); + if (!opts.fresh && transportResumeId) { + effectiveSkipCreate = true; + } // Switching from Claude CLI -> SDK must resume the inherited conversation. // Re-creating with the same sessionId makes Claude reject the turn with // "Session ID ... is already in use", which is what users were seeing. @@ -1204,7 +1224,12 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { } sdkDisplay = await getClaudeSdkRuntimeConfig().catch(() => ({})); } else if (agentType === 'codex-sdk') { + effectiveSessionKey = randomUUID(); + effectiveBindExistingKey = undefined; transportResumeId = opts.codexSessionId ?? (!opts.fresh ? getSession(name)?.codexSessionId : undefined); + if (!opts.fresh && transportResumeId) { + effectiveSkipCreate = true; + } sdkDisplay = await getCodexRuntimeConfig().catch(() => ({})); } @@ -1374,7 +1399,7 @@ export async function launchSession(opts: LaunchOpts): Promise { } } - let familyDisplay: Pick | undefined; + let familyDisplay: Pick | undefined; if (agentType === 'codex') { familyDisplay = await getCodexRuntimeConfig().catch(() => ({})); } else if (agentType === 'claude-code' && !opts.ccPreset) { diff --git a/src/agent/transport-provider.ts b/src/agent/transport-provider.ts index ef8851bb1..ac94a1ef8 100644 --- a/src/agent/transport-provider.ts +++ b/src/agent/transport-provider.ts @@ -12,6 +12,7 @@ import type { AgentMessage, MessageDelta, ToolCallEvent } from '../../shared/agent-message.js'; import type { TransportEffortLevel } from '../../shared/effort-levels.js'; +import type { ProviderQuotaMeta } from '../../shared/provider-quota.js'; // Re-export shared types used by consumers of this module so they can import from one place. export type { AgentMessage, MessageDelta, ToolCallEvent }; @@ -178,6 +179,8 @@ export interface SessionInfoUpdate { quotaLabel?: string; /** Human-readable quota progress / reset label, if known. */ quotaUsageLabel?: string; + /** Structured quota metadata for recomputing display labels. */ + quotaMeta?: ProviderQuotaMeta; /** Current reasoning/thinking effort, if known. */ effort?: TransportEffortLevel; } diff --git a/src/daemon/command-handler.ts b/src/daemon/command-handler.ts index dc80aaf0c..03244c177 100644 --- a/src/daemon/command-handler.ts +++ b/src/daemon/command-handler.ts @@ -2,7 +2,7 @@ * Handle commands from the web UI and inbound chat messages via ServerLink. * Commands arrive as JSON objects with a `type` field. */ -import { startProject, stopProject, teardownProject, getTransportRuntime, launchTransportSession, isProviderSessionBound, persistSessionRecord, relaunchSessionWithSettings, type ProjectConfig } from '../agent/session-manager.js'; +import { startProject, stopProject, teardownProject, getTransportRuntime, launchTransportSession, isProviderSessionBound, persistSessionRecord, relaunchSessionWithSettings, stopTransportRuntimeSession, type ProjectConfig } from '../agent/session-manager.js'; import { isTransportAgent } from '../agent/detect.js'; import { sendKeys, sendKeysDelayedEnter, sendRawInput, resizeSession, sendKey, getPaneStartCommand } from '../agent/tmux.js'; import { listSessions, getSession, upsertSession, removeSession, type SessionRecord } from '../store/session-store.js'; @@ -58,6 +58,8 @@ import { type TransportEffortLevel, } from '../../shared/effort-levels.js'; +const MAX_P2P_FILE_PULL_COUNT = 20; + /** * Build a unified subsession.sync payload from the session store record. * Ensures all fields (including Qwen metadata) are always sent — no more @@ -227,6 +229,12 @@ function trackPendingSessionRelaunch(sessionName: string, pending: Promise return pending; } +function runExclusiveSessionRelaunch(sessionName: string, factory: () => Promise): Promise { + const pending = pendingSessionRelaunches.get(sessionName); + if (pending) return pending; + return trackPendingSessionRelaunch(sessionName, factory()); +} + async function waitForPendingSessionRelaunch(sessionName: string): Promise { const pending = pendingSessionRelaunches.get(sessionName); if (!pending) return; @@ -923,7 +931,7 @@ async function handleRestart(cmd: Record, serverLink: ServerLin return; } try { - await trackPendingSessionRelaunch(sessionName, (async () => { + await runExclusiveSessionRelaunch(sessionName, async () => { try { await relaunchSessionWithSettings(record, { agentType: (cmd.agentType as any) ?? undefined, @@ -944,7 +952,7 @@ async function handleRestart(cmd: Record, serverLink: ServerLin await handleGetSessions(serverLink); throw err; } - })()); + }); } catch { // Failure already surfaced via session.error + corrective session_list. } @@ -1213,7 +1221,7 @@ async function handleSend(cmd: Record, serverLink: ServerLink): const fileContents: Array<{ path: string; content: string }> = []; const record = getSession(sessionName); const projectDir = record?.projectDir ?? ''; - for (const fp of tokens.files) { + for (const fp of tokens.files.slice(0, MAX_P2P_FILE_PULL_COUNT)) { try { const absPath = nodePath.isAbsolute(fp) ? fp : nodePath.join(projectDir, fp); // Check for binary content (null bytes anywhere in the capped content) @@ -1289,11 +1297,21 @@ async function handleSend(cmd: Record, serverLink: ServerLink): try { serverLink.send({ type: 'command.ack', commandId: effectiveId, status: errStatus, session: sessionName, error: errMsg }); } catch { /* not connected */ } return; } + if (transportRuntime && !transportRuntime.providerSessionId) { + await stopTransportRuntimeSession(sessionName).catch(() => {}); + const errMsg = `Provider ${record?.providerId ?? 'unknown'} restarting. Please resend in a moment.`; + logger.warn({ sessionName, providerId: record?.providerId }, 'session.send: transport runtime missing provider session id'); + emitTransportUserMessage(text); + timelineEmitter.emit(sessionName, 'assistant.text', { text: `⚠️ ${errMsg}`, streaming: false }, { source: 'daemon', confidence: 'high' }); + timelineEmitter.emit(sessionName, 'session.state', { state: 'idle', error: errMsg }, { source: 'daemon', confidence: 'high' }); + timelineEmitter.emit(sessionName, 'command.ack', { commandId: effectiveId, status: 'error', error: errMsg }); + try { serverLink.send({ type: 'command.ack', commandId: effectiveId, status: 'error', session: sessionName, error: errMsg }); } catch {} + return; + } if (transportRuntime) { - const release = await getMutex(sessionName).acquire(); - try { - if (text.trim() === '/stop') { - emitTransportUserMessage(text); + if (text.trim() === '/stop') { + emitTransportUserMessage(text); + try { await transportRuntime.cancel(); // Mark session for fresh start so daemon restart doesn't resume the stuck conversation if (record?.agentType === 'qwen') { @@ -1304,8 +1322,17 @@ async function handleSend(cmd: Record, serverLink: ServerLink): try { serverLink.send({ type: 'command.ack', commandId: effectiveId, status: stopStatus, session: sessionName }); } catch { /* */ } - return; + } catch (err) { + const errMsg = describeTransportSendError(err); + logger.error({ sessionName, err }, 'session.stop (transport) failed'); + timelineEmitter.emit(sessionName, 'assistant.text', { text: `⚠️ Stop failed: ${errMsg}`, streaming: false }, { source: 'daemon', confidence: 'high' }); + timelineEmitter.emit(sessionName, 'session.state', { state: 'idle', error: errMsg }, { source: 'daemon', confidence: 'high' }); + try { serverLink.send({ type: 'command.ack', commandId: effectiveId, status: 'error', session: sessionName, error: errMsg }); } catch { /* */ } } + return; + } + const release = await getMutex(sessionName).acquire(); + try { const modelMatch = text.trim().match(/^\/model\s+(\S+)(?:\s+.*)?$/); const effortMatch = text.trim().match(/^\/(?:thinking|effort)\s+(\S+)\s*$/); if (record?.agentType === 'qwen' && modelMatch) { @@ -1603,6 +1630,8 @@ async function handleResize(cmd: Record): Promise { const cols = cmd.cols as number | undefined; const rows = cmd.rows as number | undefined; if (!sessionName || !cols || !rows) return; + const record = getSession(sessionName); + if (record?.runtimeType === 'transport') return; try { // Subtract 1 col so tmux is always slightly narrower than the browser terminal. // xterm fitAddon rounds down but container width may have sub-character remainder, @@ -1629,6 +1658,16 @@ const RAW_BATCH_MAX_BYTES = 32 * 1024; // flush immediately at 32KB function handleSubscribe(cmd: Record, serverLink: ServerLink): void { const session = cmd.session as string | undefined; if (!session) return; + const record = getSession(session); + if (record?.runtimeType === 'transport') { + const existing = activeSubscriptions.get(session); + if (existing) { + existing.unsubscribe(); + activeSubscriptions.delete(session); + } + logger.debug({ session }, 'Terminal subscribe skipped for transport session'); + return; + } // The bridge may include a `raw` flag on terminal.subscribe for its own forwarding-mode // bookkeeping, but daemon-side terminal streaming remains transport-stable in this phase: @@ -1697,6 +1736,8 @@ function handleUnsubscribe(cmd: Record): void { function handleSnapshotRequest(cmd: Record): void { const sessionName = cmd.sessionName as string | undefined; if (!sessionName) return; + const record = getSession(sessionName); + if (record?.runtimeType === 'transport') return; terminalStreamer.requestSnapshot(sessionName); logger.debug({ sessionName }, 'Snapshot requested via web'); } @@ -2013,7 +2054,7 @@ async function handleSubSessionRestart(cmd: Record, serverLink: } const id = sName.replace(/^deck_sub_/, ''); try { - await trackPendingSessionRelaunch(sName, (async () => { + await runExclusiveSessionRelaunch(sName, async () => { try { const effectiveRecord = (await recoverOpenCodeSessionRecord(record)) ?? record; await relaunchSessionWithSettings(effectiveRecord, { @@ -2032,7 +2073,7 @@ async function handleSubSessionRestart(cmd: Record, serverLink: logger.error({ err: e, sessionName: sName }, 'subsession.restart failed'); throw e; } - })()); + }); } catch { // Failure already logged; keep command handler alive for future sends. } @@ -2319,6 +2360,23 @@ async function handleDaemonUpgrade(targetVersion?: string, serverLink?: ServerLi return; } + const activeTransportSessions = getActiveTransportSessionsBlockingDaemonUpgrade(); + if (activeTransportSessions.length > 0) { + logger.warn({ + targetVersion, + activeSessionNames: activeTransportSessions.map((session) => session.name), + activeSessionStates: activeTransportSessions.map((session) => session.state), + }, 'daemon.upgrade: blocked because transport sessions have active turns'); + try { + serverLink?.send({ + type: DAEMON_MSG.UPGRADE_BLOCKED, + reason: 'transport_busy', + activeSessionNames: activeTransportSessions.map((session) => session.name), + }); + } catch { /* ignore */ } + return; + } + const { spawn } = await import('child_process'); const { writeFileSync, mkdtempSync, existsSync } = await import('fs'); const { join, dirname } = await import('path'); @@ -2523,6 +2581,15 @@ export function getActiveP2pRunsBlockingDaemonUpgrade(runs = listP2pRuns()) { return runs.filter((run) => !P2P_TERMINAL_RUN_STATUSES.has(run.status)); } +export function getActiveTransportSessionsBlockingDaemonUpgrade(sessions = listSessions()) { + return sessions.filter((session) => { + if (session.runtimeType !== 'transport') return false; + const runtime = getTransportRuntime(session.name); + if (!runtime) return false; + return runtime.getStatus() !== 'idle' || runtime.sending || runtime.pendingCount > 0; + }); +} + async function handleFileSearch(cmd: Record, serverLink: ServerLink): Promise { const query = (cmd.query as string ?? '').trim(); const projectDir = cmd.projectDir as string | undefined; @@ -2561,6 +2628,7 @@ async function handleFileSearch(cmd: Record, serverLink: Server const fzf = new Fzf(allPaths, { fuzzy: allPaths.length > 20000 ? 'v1' : 'v2', forward: false, + casing: 'case-insensitive', tiebreakers: [fileSearchByBasenamePrefix, fileSearchByMatchPosFromEnd, fileSearchByLengthAsc], }); const results = fzf.find(query); diff --git a/src/daemon/cron-executor.ts b/src/daemon/cron-executor.ts index ae305634e..31c1836b0 100644 --- a/src/daemon/cron-executor.ts +++ b/src/daemon/cron-executor.ts @@ -96,7 +96,10 @@ export async function executeCronJob(msg: CronDispatchMessage, serverLink: Serve const runtime = getTransportRuntime(name); if (runtime) { try { - await runtime.send(action.command); + const result = await runtime.send(action.command); + if (result !== 'queued') { + timelineEmitter.emit(name, 'user.message', { text: action.command, allowDuplicate: true }); + } } catch (err) { logger.error({ jobId, sessionName: name, err }, 'Cron: transport send failed'); sendCommandResult(serverLink, { @@ -119,6 +122,7 @@ export async function executeCronJob(msg: CronDispatchMessage, serverLink: Serve } } else { await sendKeys(name, action.command, { cwd: session.projectDir }); + timelineEmitter.emit(name, 'user.message', { text: action.command, allowDuplicate: true }); } // Capture agent response: collect assistant.text events until session goes idle diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts index d05057076..f13891b9a 100644 --- a/src/daemon/lifecycle.ts +++ b/src/daemon/lifecycle.ts @@ -27,6 +27,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { P2P_TERMINAL_RUN_STATUSES } from '../../shared/p2p-status.js'; import { pickReadableSessionDisplay } from '../../shared/session-display.js'; +import { buildWorkerSessionPersistBody, mergeWorkerSessionSnapshot } from './session-bootstrap.js'; /** Get the last assistant.text from a session's timeline (for push notification context). */ function getLastAssistantText(sessionName: string): string | undefined { @@ -102,25 +103,11 @@ async function persistSessionToWorker( record: import('../store/session-store.js').SessionRecord, ): Promise { try { + const payload = buildWorkerSessionPersistBody(record); const res = await fetch(`${workerUrl}/api/server/${serverId}/sessions/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'X-Server-Id': serverId }, - body: JSON.stringify({ - projectName: record.projectName, - projectRole: record.role, - agentType: record.agentType, - agentVersion: record.agentVersion, - projectDir: record.projectDir, - state: record.state, - runtimeType: record.runtimeType ?? null, - providerId: record.providerId ?? null, - providerSessionId: record.providerSessionId ?? null, - description: record.description ?? null, - requestedModel: record.requestedModel ?? null, - activeModel: record.activeModel ?? record.modelDisplay ?? null, - effort: record.effort ?? null, - transportConfig: record.transportConfig ?? null, - }), + body: JSON.stringify(payload), }); if (!res.ok) logger.warn({ status: res.status, name }, 'persistSessionToWorker: non-ok response'); } catch (e) { @@ -185,7 +172,7 @@ async function syncSessionsFromWorker(workerUrl: string, serverId: string, token return; } - const data = await sessionRes.json() as { sessions: Array<{ name: string; project_name: string; role: string; agent_type: string; project_dir: string; state: string; requested_model?: string | null; active_model?: string | null; effort?: SessionRecord['effort'] | null; transport_config?: Record | string | null }> }; + const data = await sessionRes.json() as { sessions: Array<{ name: string; project_name: string; role: string; agent_type: string; project_dir: string; state: string; label?: string | null; requested_model?: string | null; active_model?: string | null; effort?: SessionRecord['effort'] | null; transport_config?: Record | string | null }> }; const subData = await subRes.json() as { subSessions: Array<{ id: string }> }; const remoteSessionNames = new Set( data.sessions @@ -211,28 +198,7 @@ async function syncSessionsFromWorker(workerUrl: string, serverId: string, token for (const s of data.sessions) { if (s.state === 'stopped') continue; // skip stopped sessions const existing = getSession(s.name); - // Merge with existing local record to preserve fields not stored in server DB - // (ccSessionId, codexSessionId, geminiSessionId, restarts, etc.) - upsertSession({ - ...(existing ?? {}), - name: s.name, - projectName: s.project_name, - role: s.role as 'brain' | `w${number}`, - agentType: s.agent_type, - projectDir: s.project_dir, - state: s.state as import('../store/session-store.js').SessionState, - requestedModel: s.requested_model ?? existing?.requestedModel, - activeModel: s.active_model ?? existing?.activeModel, - modelDisplay: s.active_model ?? existing?.modelDisplay, - effort: s.effort ?? existing?.effort, - transportConfig: (typeof s.transport_config === 'string' - ? JSON.parse(s.transport_config) - : (s.transport_config ?? existing?.transportConfig)) as Record | undefined, - restarts: existing?.restarts ?? 0, - restartTimestamps: existing?.restartTimestamps ?? [], - createdAt: existing?.createdAt ?? Date.now(), - updatedAt: Date.now(), - }); + upsertSession(mergeWorkerSessionSnapshot(existing, s)); count++; } logger.info({ count }, 'Sessions synced from D1'); diff --git a/src/daemon/session-bootstrap.ts b/src/daemon/session-bootstrap.ts new file mode 100644 index 000000000..2f278fd84 --- /dev/null +++ b/src/daemon/session-bootstrap.ts @@ -0,0 +1,80 @@ +import type { SessionRecord } from '../store/session-store.js'; + +export interface WorkerSessionSnapshot { + name: string; + project_name: string; + role: string; + agent_type: string; + project_dir: string; + state: string; + label?: string | null; + requested_model?: string | null; + active_model?: string | null; + effort?: SessionRecord['effort'] | null; + transport_config?: Record | string | null; +} + +export interface WorkerSessionPersistBody { + projectName: string; + projectRole: string; + agentType: string; + agentVersion: string | null; + projectDir: string; + state: string; + label: string | null; + runtimeType: string | null; + providerId: string | null; + providerSessionId: string | null; + description: string | null; + requestedModel: string | null; + activeModel: string | null; + effort: SessionRecord['effort'] | null; + transportConfig: Record | null; +} + +export function buildWorkerSessionPersistBody(record: SessionRecord): WorkerSessionPersistBody { + return { + projectName: record.projectName, + projectRole: record.role, + agentType: record.agentType, + agentVersion: record.agentVersion ?? null, + projectDir: record.projectDir, + state: record.state, + label: record.label ?? null, + runtimeType: record.runtimeType ?? null, + providerId: record.providerId ?? null, + providerSessionId: record.providerSessionId ?? null, + description: record.description ?? null, + requestedModel: record.requestedModel ?? null, + activeModel: record.activeModel ?? record.modelDisplay ?? null, + effort: record.effort ?? null, + transportConfig: record.transportConfig ?? null, + }; +} + +export function mergeWorkerSessionSnapshot( + existing: SessionRecord | undefined, + snapshot: WorkerSessionSnapshot, +): SessionRecord { + return { + ...(existing ?? {}), + name: snapshot.name, + projectName: snapshot.project_name, + role: snapshot.role as 'brain' | `w${number}`, + agentType: snapshot.agent_type, + projectDir: snapshot.project_dir, + state: snapshot.state as SessionRecord['state'], + label: snapshot.label ?? undefined, + requestedModel: snapshot.requested_model ?? existing?.requestedModel, + activeModel: snapshot.active_model ?? existing?.activeModel, + modelDisplay: snapshot.active_model ?? existing?.modelDisplay, + effort: snapshot.effort ?? existing?.effort, + transportConfig: (typeof snapshot.transport_config === 'string' + ? JSON.parse(snapshot.transport_config) + : (snapshot.transport_config ?? existing?.transportConfig)) as Record | undefined, + restarts: existing?.restarts ?? 0, + restartTimestamps: existing?.restartTimestamps ?? [], + createdAt: existing?.createdAt ?? Date.now(), + updatedAt: Date.now(), + }; +} diff --git a/src/daemon/transport-relay.ts b/src/daemon/transport-relay.ts index b4ef16015..3d2d11477 100644 --- a/src/daemon/transport-relay.ts +++ b/src/daemon/transport-relay.ts @@ -39,6 +39,28 @@ function clearPendingStreamUpdate(eventId: string): void { pendingStreamUpdates.delete(eventId); } +function normalizeUsageUpdatePayload( + usage: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + } | undefined, + model: string | undefined, +): Record | null { + if (!usage && !model) return null; + const inputTokens = typeof usage?.input_tokens === 'number' + ? usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + : undefined; + const payload: Record = { + ...(typeof inputTokens === 'number' ? { inputTokens } : {}), + ...(typeof usage?.cache_read_input_tokens === 'number' ? { cacheTokens: usage.cache_read_input_tokens } : {}), + ...(model ? { model } : {}), + contextWindow: resolveContextWindow(undefined, model), + }; + return payload; +} + function flushPendingStreamUpdate(eventId: string): void { const pending = pendingStreamUpdates.get(eventId); if (!pending || pending.pendingText == null) return; @@ -137,15 +159,12 @@ export function wireProviderToRelay(provider: TransportProvider): void { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; } | undefined; const model = typeof message.metadata?.model === 'string' ? message.metadata.model : undefined; - if (usage || model) { - timelineEmitter.emit(sessionName, 'usage.update', { - ...(typeof usage?.input_tokens === 'number' ? { inputTokens: usage.input_tokens } : {}), - ...(typeof usage?.cache_read_input_tokens === 'number' ? { cacheTokens: usage.cache_read_input_tokens } : {}), - ...(model ? { model } : {}), - contextWindow: resolveContextWindow(undefined, model), - }, { source: 'daemon', confidence: 'high' }); + const usagePayload = normalizeUsageUpdatePayload(usage, model); + if (usagePayload) { + timelineEmitter.emit(sessionName, 'usage.update', usagePayload, { source: 'daemon', confidence: 'high' }); } // Emit idle state diff --git a/test/agent/claude-code-sdk-provider.test.ts b/test/agent/claude-code-sdk-provider.test.ts index a9daf58a8..4f8db2798 100644 --- a/test/agent/claude-code-sdk-provider.test.ts +++ b/test/agent/claude-code-sdk-provider.test.ts @@ -9,7 +9,15 @@ const childProcessMock = vi.hoisted(() => ({ cb?.(null, 'ok\n', ''); return {} as never; }), - spawn: vi.fn(() => ({} as never)), + spawn: vi.fn(() => ({ + killed: false, + kill: vi.fn(function (this: { killed: boolean }) { + this.killed = true; + return true; + }), + once: vi.fn(), + on: vi.fn(), + }) as never), })); vi.mock('node:child_process', () => ({ @@ -20,26 +28,30 @@ vi.mock('node:child_process', () => ({ const sdkMock = vi.hoisted(() => { let nextMessages: any[] = []; let waitForClose = false; - const runs: Array<{ prompt: string; options: Record; closed: boolean; interrupted: boolean }> = []; + let interruptNeverResolves = false; + const runs: Array<{ prompt: string; options: Record; closed: boolean; interrupted: boolean; resolveClose?: () => void }> = []; const query = vi.fn(({ prompt, options }: { prompt: string; options: Record }) => { - const run = { prompt, options, closed: false, interrupted: false }; + const run = { prompt, options, closed: false, interrupted: false, resolveClose: undefined as (() => void) | undefined }; runs.push(run); async function* gen() { for (const message of nextMessages) yield message; if (waitForClose) { await new Promise((resolve) => { - const timer = setInterval(() => { - if (run.closed) { - clearInterval(timer); - resolve(); - } - }, 0); + run.resolveClose = resolve; }); } } const iterator = gen() as AsyncGenerator & { close(): void; interrupt(): Promise }; - iterator.close = () => { run.closed = true; }; - iterator.interrupt = async () => { run.interrupted = true; }; + iterator.close = () => { + run.closed = true; + run.resolveClose?.(); + }; + iterator.interrupt = async () => { + run.interrupted = true; + if (interruptNeverResolves) { + await new Promise(() => {}); + } + }; return iterator; }); return { @@ -47,6 +59,7 @@ const sdkMock = vi.hoisted(() => { runs, setNextMessages(messages: any[]) { nextMessages = messages; }, setWaitForClose(value: boolean) { waitForClose = value; }, + setInterruptNeverResolves(value: boolean) { interruptNeverResolves = value; }, }; }); @@ -64,10 +77,13 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); describe('ClaudeCodeSdkProvider', () => { beforeEach(() => { + vi.useRealTimers(); sdkMock.query.mockClear(); sdkMock.runs.length = 0; sdkMock.setNextMessages([]); sdkMock.setWaitForClose(false); + sdkMock.setInterruptNeverResolves(false); + childProcessMock.spawn.mockClear(); }); it('uses stable resume id, emits cumulative text deltas, and completes from result', async () => { @@ -113,6 +129,63 @@ describe('ClaudeCodeSdkProvider', () => { expect(sessionInfo.some((info) => info.model === 'claude-sonnet-4-6')).toBe(true); }); + it('uses the last assistant usage for completion metadata instead of cumulative result usage', async () => { + sdkMock.setNextMessages([ + { + type: 'assistant', + session_id: 'session-usage', + message: { + content: [{ type: 'text', text: 'Hello' }], + usage: { + input_tokens: 120, + cache_creation_input_tokens: 30, + cache_read_input_tokens: 20, + output_tokens: 10, + }, + }, + }, + { + type: 'result', + session_id: 'session-usage', + subtype: 'success', + is_error: false, + result: 'Hello', + usage: { + input_tokens: 999, + cache_creation_input_tokens: 400, + cache_read_input_tokens: 300, + output_tokens: 50, + }, + }, + ]); + + const provider = new ClaudeCodeSdkProvider(); + await provider.connect({ binaryPath: 'claude' }); + await provider.createSession({ sessionKey: 'route-usage', cwd: '/tmp/project', resumeId: 'session-usage' }); + + const completed: AgentMessage[] = []; + provider.onComplete((_sid, msg) => completed.push(msg)); + + await provider.send('route-usage', 'hello'); + await flush(); + + expect(completed).toHaveLength(1); + expect(completed[0]?.metadata).toMatchObject({ + usage: { + input_tokens: 120, + cache_creation_input_tokens: 30, + cache_read_input_tokens: 20, + output_tokens: 10, + }, + totalUsage: { + input_tokens: 999, + cache_creation_input_tokens: 400, + cache_read_input_tokens: 300, + output_tokens: 50, + }, + }); + }); + it('emits cancelled on cancel()', async () => { sdkMock.setWaitForClose(true); @@ -133,6 +206,28 @@ describe('ClaudeCodeSdkProvider', () => { expect(errors).toContain('CANCELLED'); }); + it('force-kills the Claude child when cancel interrupt hangs', async () => { + vi.useFakeTimers(); + sdkMock.setWaitForClose(true); + + const provider = new ClaudeCodeSdkProvider(); + await provider.connect({ binaryPath: 'claude' }); + await provider.createSession({ sessionKey: 'route-hung-cancel', cwd: '/tmp/project', resumeId: 'session-hung-cancel' }); + + await provider.send('route-hung-cancel', 'hello'); + const run = sdkMock.runs[0]!; + const spawnFn = run.options.spawnClaudeCodeProcess as ((req: { command: string; args: string[]; cwd?: string; env?: Record; signal?: AbortSignal }) => any); + const child = spawnFn({ command: 'claude', args: ['--fake'] }); + sdkMock.setInterruptNeverResolves(true); + + const cancelPromise = provider.cancel('route-hung-cancel'); + await vi.advanceTimersByTimeAsync(1_600); + await cancelPromise; + + expect(run.closed).toBe(true); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + }); + it('fresh createSession ignores previous internal continuity for the same route', async () => { const provider = new ClaudeCodeSdkProvider(); await provider.connect({ binaryPath: 'claude' }); diff --git a/test/agent/codex-sdk-provider.test.ts b/test/agent/codex-sdk-provider.test.ts index 1bbc2fd82..3da822105 100644 --- a/test/agent/codex-sdk-provider.test.ts +++ b/test/agent/codex-sdk-provider.test.ts @@ -111,6 +111,7 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); describe('CodexSdkProvider', () => { beforeEach(() => { + vi.useRealTimers(); childProcessMock.spawn.mockClear(); childProcessMock.execFile.mockClear(); childProcessMock.children.length = 0; @@ -249,6 +250,28 @@ describe('CodexSdkProvider', () => { expect(child.requests.some((req) => req.method === 'turn/interrupt')).toBe(true); }); + it('recovers the session when turn/interrupt never produces an interrupted completion', async () => { + vi.useFakeTimers(); + const provider = new CodexSdkProvider(); + await provider.connect({ binaryPath: 'codex' }); + await provider.createSession({ sessionKey: 'route-cancel-timeout', cwd: '/tmp/project' }); + + const errors: string[] = []; + provider.onError((_sid, err) => errors.push(err.code)); + + await provider.send('route-cancel-timeout', 'hello'); + const child = childProcessMock.children[0]; + + await provider.cancel('route-cancel-timeout'); + await vi.advanceTimersByTimeAsync(1_600); + + expect(child.requests.some((req) => req.method === 'turn/interrupt')).toBe(true); + expect(errors).toContain('CANCELLED'); + + await provider.send('route-cancel-timeout', 'after-cancel'); + expect(child.requests.filter((req) => req.method === 'turn/start')).toHaveLength(2); + }); + it('emits WebSearch tool events for webSearch items', async () => { const provider = new CodexSdkProvider(); await provider.connect({ binaryPath: 'codex' }); diff --git a/test/daemon/command-handler-stop.test.ts b/test/daemon/command-handler-stop.test.ts index f0f2069ed..2a1ca0c8f 100644 --- a/test/daemon/command-handler-stop.test.ts +++ b/test/daemon/command-handler-stop.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { stopProjectMock, stopSubSessionMock, loggerErrorMock, loggerWarnMock, buildSessionListMock } = vi.hoisted(() => ({ +const { stopProjectMock, stopSubSessionMock, loggerErrorMock, loggerWarnMock, buildSessionListMock, getTransportRuntimeMock } = vi.hoisted(() => ({ stopProjectMock: vi.fn(), stopSubSessionMock: vi.fn().mockResolvedValue({ ok: true, closed: ['deck_sub_worker'], failed: [] }), loggerErrorMock: vi.fn(), loggerWarnMock: vi.fn(), buildSessionListMock: vi.fn(async () => []), + getTransportRuntimeMock: vi.fn(() => undefined), })); vi.mock('../../src/store/session-store.js', () => ({ @@ -19,7 +20,7 @@ vi.mock('../../src/agent/session-manager.js', () => ({ startProject: vi.fn(), stopProject: stopProjectMock, teardownProject: vi.fn(), - getTransportRuntime: vi.fn(() => undefined), + getTransportRuntime: getTransportRuntimeMock, launchTransportSession: vi.fn(), isProviderSessionBound: vi.fn(() => false), persistSessionRecord: vi.fn(), @@ -170,6 +171,31 @@ describe('handleWebCommand shutdown failure paths', () => { }); }); + it('blocks daemon.upgrade when a transport session still has an active turn', async () => { + const { listSessions } = await import('../../src/store/session-store.js'); + vi.mocked(listSessions).mockReturnValue([ + { + name: 'deck_proj_brain', + runtimeType: 'transport', + state: 'running', + } as any, + ]); + getTransportRuntimeMock.mockReturnValue({ + getStatus: () => 'thinking', + sending: true, + pendingCount: 0, + } as any); + + handleWebCommand({ type: 'daemon.upgrade' }, serverLink as any); + await flushAsync(); + + expect(serverLink.send).toHaveBeenCalledWith({ + type: 'daemon.upgrade_blocked', + reason: 'transport_busy', + activeSessionNames: ['deck_proj_brain'], + }); + }); + it('updates the main-session project name and pushes a refreshed session_list on session.rename', async () => { const { getSession, upsertSession } = await import('../../src/store/session-store.js'); vi.mocked(getSession).mockReturnValue({ @@ -265,6 +291,53 @@ describe('handleWebCommand shutdown failure paths', () => { }); }); + it('clears the main-session label and pushes a refreshed session_list on session.relabel', async () => { + const { getSession, upsertSession } = await import('../../src/store/session-store.js'); + vi.mocked(getSession).mockReturnValue({ + name: 'deck_proj_brain', + projectName: 'proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + label: 'Main Label', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + projectDir: '/tmp/proj', + } as any); + buildSessionListMock.mockResolvedValueOnce([ + { + name: 'deck_proj_brain', + project: 'proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + }, + ]); + + handleWebCommand({ type: 'session.relabel', sessionName: 'deck_proj_brain', label: null }, serverLink as any); + await flushAsync(); + + expect(upsertSession).toHaveBeenCalledWith(expect.objectContaining({ + name: 'deck_proj_brain', + label: undefined, + })); + expect(serverLink.send).toHaveBeenCalledWith({ + type: 'session_list', + daemonVersion: '0.1.0', + sessions: [ + { + name: 'deck_proj_brain', + project: 'proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + }, + ], + }); + }); + it('updates the sub-session label and emits subsession.sync on subsession.rename', async () => { const { getSession, upsertSession } = await import('../../src/store/session-store.js'); vi.mocked(getSession).mockReturnValue({ diff --git a/test/daemon/command-handler-transport-queue.test.ts b/test/daemon/command-handler-transport-queue.test.ts index c0564284a..f3a3c9644 100644 --- a/test/daemon/command-handler-transport-queue.test.ts +++ b/test/daemon/command-handler-transport-queue.test.ts @@ -1,10 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { getSessionMock, getTransportRuntimeMock, emitMock, relaunchSessionWithSettingsMock } = vi.hoisted(() => ({ +const { getSessionMock, getTransportRuntimeMock, emitMock, relaunchSessionWithSettingsMock, stopTransportRuntimeSessionMock, resizeSessionMock, terminalSubscribeMock, terminalRequestSnapshotMock } = vi.hoisted(() => ({ getSessionMock: vi.fn(), getTransportRuntimeMock: vi.fn(), emitMock: vi.fn(), relaunchSessionWithSettingsMock: vi.fn(), + stopTransportRuntimeSessionMock: vi.fn().mockResolvedValue(undefined), + resizeSessionMock: vi.fn(), + terminalSubscribeMock: vi.fn(() => vi.fn()), + terminalRequestSnapshotMock: vi.fn(), })); vi.mock('../../src/store/session-store.js', () => ({ @@ -24,13 +28,14 @@ vi.mock('../../src/agent/session-manager.js', () => ({ isProviderSessionBound: vi.fn(() => false), persistSessionRecord: vi.fn(), relaunchSessionWithSettings: relaunchSessionWithSettingsMock, + stopTransportRuntimeSession: stopTransportRuntimeSessionMock, })); vi.mock('../../src/agent/tmux.js', () => ({ sendKeys: vi.fn(), sendKeysDelayedEnter: vi.fn(), sendRawInput: vi.fn(), - resizeSession: vi.fn(), + resizeSession: resizeSessionMock, sendKey: vi.fn(), getPaneStartCommand: vi.fn(), })); @@ -41,10 +46,12 @@ vi.mock('../../src/router/message-router.js', () => ({ vi.mock('../../src/daemon/terminal-streamer.js', () => ({ terminalStreamer: { - subscribe: vi.fn(), + subscribe: terminalSubscribeMock, unsubscribe: vi.fn(), start: vi.fn(), stop: vi.fn(), + requestSnapshot: terminalRequestSnapshotMock, + invalidateSize: vi.fn(), }, })); @@ -142,6 +149,7 @@ describe('handleWebCommand transport queue behavior', () => { it('does not emit a user.message for queued transport sends', async () => { getTransportRuntimeMock.mockReturnValue({ + providerSessionId: 'route-transport', send: vi.fn(() => 'queued'), pendingCount: 2, pendingMessages: ['queued msg', 'queued msg 2'], @@ -160,8 +168,33 @@ describe('handleWebCommand transport queue behavior', () => { expect(emitMock).toHaveBeenCalledWith('deck_transport_brain', 'command.ack', { commandId: 'cmd-queued', status: 'accepted' }); }); + it('dispatches /stop immediately for transport sessions without emitting queued state', async () => { + const cancel = vi.fn().mockResolvedValue(undefined); + getTransportRuntimeMock.mockReturnValue({ + providerSessionId: 'route-transport', + cancel, + send: vi.fn(() => 'queued'), + pendingCount: 3, + pendingMessages: ['a', 'b', 'c'], + }); + + handleWebCommand({ type: 'session.send', session: 'deck_transport_brain', text: '/stop', commandId: 'cmd-stop' }, serverLink as any); + await flushAsync(); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(emitMock).toHaveBeenCalledWith('deck_transport_brain', 'user.message', { text: '/stop', allowDuplicate: true }); + expect(emitMock).toHaveBeenCalledWith('deck_transport_brain', 'command.ack', { commandId: 'cmd-stop', status: 'accepted' }); + expect(emitMock).not.toHaveBeenCalledWith( + 'deck_transport_brain', + 'session.state', + expect.objectContaining({ state: 'queued' }), + expect.anything(), + ); + }); + it('emits a user.message immediately for dispatched transport sends', async () => { getTransportRuntimeMock.mockReturnValue({ + providerSessionId: 'route-transport', send: vi.fn(() => 'sent'), pendingCount: 0, }); @@ -181,6 +214,7 @@ describe('handleWebCommand transport queue behavior', () => { it('does not short-circuit transport identity questions in the daemon', async () => { const transportSend = vi.fn(() => 'sent'); getTransportRuntimeMock.mockReturnValue({ + providerSessionId: 'route-transport', send: transportSend, pendingCount: 0, }); @@ -203,6 +237,40 @@ describe('handleWebCommand transport queue behavior', () => { ); }); + it('treats transport runtimes without a provider session id as unavailable', async () => { + getTransportRuntimeMock.mockReturnValue({ + providerSessionId: null, + send: vi.fn(() => { + throw new Error('TransportSessionRuntime not initialized — call initialize() first'); + }), + pendingCount: 0, + pendingMessages: [], + }); + + handleWebCommand({ + type: 'session.send', + session: 'deck_transport_brain', + text: 'hello after restart', + commandId: 'cmd-stale-runtime', + }, serverLink as any); + await flushAsync(); + + expect(stopTransportRuntimeSessionMock).toHaveBeenCalledWith('deck_transport_brain'); + expect(emitMock).toHaveBeenCalledWith( + 'deck_transport_brain', + 'assistant.text', + { text: '⚠️ Provider unknown restarting. Please resend in a moment.', streaming: false }, + expect.any(Object), + ); + expect(serverLink.send).toHaveBeenCalledWith({ + type: 'command.ack', + commandId: 'cmd-stale-runtime', + status: 'error', + session: 'deck_transport_brain', + error: 'Provider unknown restarting. Please resend in a moment.', + }); + }); + it('waits for an in-flight settings restart before sending the first transport message', async () => { let restartResolved = false; let resolveRestart: (() => void) | null = null; @@ -224,7 +292,7 @@ describe('handleWebCommand transport queue behavior', () => { })); const transportSend = vi.fn(() => 'sent'); getTransportRuntimeMock.mockImplementation(() => ( - restartResolved ? { send: transportSend, pendingCount: 0 } : undefined + restartResolved ? { providerSessionId: 'route-transport', send: transportSend, pendingCount: 0 } : undefined )); handleWebCommand({ type: 'session.restart', sessionName: 'deck_transport_brain', agentType: 'claude-code-sdk' }, serverLink as any); @@ -242,4 +310,39 @@ describe('handleWebCommand transport queue behavior', () => { expect(emitMock).toHaveBeenCalledWith('deck_transport_brain', 'user.message', { text: 'after restart', allowDuplicate: true }); expect(emitMock).toHaveBeenCalledWith('deck_transport_brain', 'command.ack', { commandId: 'cmd-after-restart', status: 'accepted' }); }); + + it('deduplicates concurrent session.restart requests for the same transport session', async () => { + let resolveRestart: (() => void) | null = null; + relaunchSessionWithSettingsMock.mockImplementation( + () => new Promise((resolve) => { + resolveRestart = resolve; + }), + ); + + handleWebCommand({ type: 'session.restart', sessionName: 'deck_transport_brain', agentType: 'claude-code-sdk' }, serverLink as any); + handleWebCommand({ type: 'session.restart', sessionName: 'deck_transport_brain', agentType: 'claude-code-sdk' }, serverLink as any); + + await flushAsync(); + expect(relaunchSessionWithSettingsMock).toHaveBeenCalledTimes(1); + + resolveRestart?.(); + await flushAsync(); + await flushAsync(); + }); + + it('skips terminal subscribe and snapshot requests for transport sessions', async () => { + handleWebCommand({ type: 'terminal.subscribe', session: 'deck_transport_brain' }, serverLink as any); + handleWebCommand({ type: 'terminal.snapshot_request', sessionName: 'deck_transport_brain' }, serverLink as any); + await flushAsync(); + + expect(terminalSubscribeMock).not.toHaveBeenCalled(); + expect(terminalRequestSnapshotMock).not.toHaveBeenCalled(); + }); + + it('skips tmux resize for transport sessions', async () => { + handleWebCommand({ type: 'session.resize', sessionName: 'deck_transport_brain', cols: 200, rows: 50 }, serverLink as any); + await flushAsync(); + + expect(resizeSessionMock).not.toHaveBeenCalled(); + }); }); diff --git a/test/daemon/cron-executor.test.ts b/test/daemon/cron-executor.test.ts index c61021508..5ac9f5c5d 100644 --- a/test/daemon/cron-executor.test.ts +++ b/test/daemon/cron-executor.test.ts @@ -23,12 +23,14 @@ vi.mock('../../src/daemon/p2p-orchestrator.js', () => ({ startP2pRun: vi.fn(), })); -const { timelineOn } = vi.hoisted(() => ({ +const { timelineOn, timelineEmit } = vi.hoisted(() => ({ timelineOn: vi.fn(), + timelineEmit: vi.fn(), })); vi.mock('../../src/daemon/timeline-emitter.js', () => ({ timelineEmitter: { on: timelineOn, + emit: timelineEmit, }, })); @@ -108,6 +110,11 @@ describe('executeCronJob', () => { 'review the codebase', { cwd: '/home/user/myapp' }, ); + expect(timelineEmit).toHaveBeenCalledWith( + 'deck_myapp_brain', + 'user.message', + { text: 'review the codebase', allowDuplicate: true }, + ); }); // 2. Command to streaming session — skips (busy) @@ -213,7 +220,7 @@ describe('executeCronJob', () => { // 10. Transport session — skips busy check, calls runtime.send() it('sends command to transport session via runtime.send(), skipping busy check', async () => { - const mockRuntime = { send: vi.fn().mockResolvedValue(undefined) }; + const mockRuntime = { send: vi.fn().mockReturnValue('sent') }; (getSession as ReturnType).mockReturnValue( makeSession({ runtimeType: 'transport' }), ); @@ -224,6 +231,28 @@ describe('executeCronJob', () => { expect(detectStatusAsync).not.toHaveBeenCalled(); expect(mockRuntime.send).toHaveBeenCalledWith('review the codebase'); expect(sendKeys).not.toHaveBeenCalled(); + expect(timelineEmit).toHaveBeenCalledWith( + 'deck_myapp_brain', + 'user.message', + { text: 'review the codebase', allowDuplicate: true }, + ); + }); + + it('does not emit a user.message when a transport cron command is only queued', async () => { + const mockRuntime = { send: vi.fn().mockReturnValue('queued') }; + (getSession as ReturnType).mockReturnValue( + makeSession({ runtimeType: 'transport' }), + ); + (getTransportRuntime as ReturnType).mockReturnValue(mockRuntime); + + await executeCronJob(makeMsg(), mockServerLink); + + expect(mockRuntime.send).toHaveBeenCalledWith('review the codebase'); + expect(timelineEmit).not.toHaveBeenCalledWith( + 'deck_myapp_brain', + 'user.message', + expect.anything(), + ); }); // 11. Transport session with disconnected provider — skips, logs warning diff --git a/test/daemon/daemon-upgrade-guard.test.ts b/test/daemon/daemon-upgrade-guard.test.ts index b1a46c1b0..71e85a53a 100644 --- a/test/daemon/daemon-upgrade-guard.test.ts +++ b/test/daemon/daemon-upgrade-guard.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getActiveP2pRunsBlockingDaemonUpgrade } from '../../src/daemon/command-handler.js'; +import { getActiveP2pRunsBlockingDaemonUpgrade, getActiveTransportSessionsBlockingDaemonUpgrade } from '../../src/daemon/command-handler.js'; +import * as sessionManager from '../../src/agent/session-manager.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); describe('getActiveP2pRunsBlockingDaemonUpgrade', () => { it('returns active runs that should block daemon upgrades', () => { @@ -25,3 +30,33 @@ describe('getActiveP2pRunsBlockingDaemonUpgrade', () => { expect(blocked).toEqual([]); }); }); + +describe('getActiveTransportSessionsBlockingDaemonUpgrade', () => { + it('returns transport sessions that still have active turns', () => { + vi.spyOn(sessionManager, 'getTransportRuntime').mockImplementation((name: string) => { + if (name === 'deck_proj_brain') { + return { + getStatus: () => 'thinking', + sending: true, + pendingCount: 1, + } as any; + } + if (name === 'deck_proj_idle') { + return { + getStatus: () => 'idle', + sending: false, + pendingCount: 0, + } as any; + } + return undefined; + }); + + const blocked = getActiveTransportSessionsBlockingDaemonUpgrade([ + { name: 'deck_proj_brain', runtimeType: 'transport' }, + { name: 'deck_proj_idle', runtimeType: 'transport' }, + { name: 'deck_proj_worker', runtimeType: 'process' }, + ] as any); + + expect(blocked.map((session) => session.name)).toEqual(['deck_proj_brain']); + }); +}); diff --git a/test/daemon/file-search.test.ts b/test/daemon/file-search.test.ts index a4a52409c..80ffafc9b 100644 --- a/test/daemon/file-search.test.ts +++ b/test/daemon/file-search.test.ts @@ -111,12 +111,21 @@ describe('fzf integration', () => { it('handles case insensitive matching', async () => { const { Fzf } = await import('fzf'); const paths = ['src/ChatView.tsx', 'src/chatview.ts']; - const fzf = new Fzf(paths, { fuzzy: 'v2', forward: false, tiebreakers: [] }); + const fzf = new Fzf(paths, { fuzzy: 'v2', forward: false, casing: 'case-insensitive', tiebreakers: [] }); const results = fzf.find('chatview').map((r) => r.item); expect(results.length).toBe(2); }); + it('keeps matching case-insensitive even when query contains uppercase letters', async () => { + const { Fzf } = await import('fzf'); + const paths = ['src/chatview.ts', 'src/components/chat-tools.ts']; + const fzf = new Fzf(paths, { fuzzy: 'v2', forward: false, casing: 'case-insensitive', tiebreakers: [] }); + + const results = fzf.find('ChatView').map((r) => r.item); + expect(results).toContain('src/chatview.ts'); + }); + it('matches across path segments', async () => { const { Fzf } = await import('fzf'); const paths = [ diff --git a/test/daemon/launch-session-codex.test.ts b/test/daemon/launch-session-codex.test.ts index a99be6db7..82339c845 100644 --- a/test/daemon/launch-session-codex.test.ts +++ b/test/daemon/launch-session-codex.test.ts @@ -67,6 +67,10 @@ vi.mock('../../src/agent/codex-runtime-config.js', () => ({ getCodexRuntimeConfig: vi.fn().mockResolvedValue({ planLabel: 'Pro', quotaLabel: '5h 11% 2h03m 4/5 13:00 · 7d 50% 1d04h 4/7 14:00', + quotaMeta: { + primary: { usedPercent: 11, windowDurationMins: 300, resetsAt: 1_700_000_000 }, + secondary: { usedPercent: 50, windowDurationMins: 10080, resetsAt: 1_700_100_000 }, + }, }), })); @@ -102,6 +106,10 @@ describe('launchSession — Codex ID handling', () => { const lastRecord = upsertCalls[upsertCalls.length - 1][0]; expect(lastRecord.codexSessionId).toBe('new-codex-uuid'); expect(lastRecord.state).toBe('idle'); + expect(lastRecord.quotaMeta).toEqual({ + primary: { usedPercent: 11, windowDurationMins: 300, resetsAt: 1_700_000_000 }, + secondary: { usedPercent: 50, windowDurationMins: 10080, resetsAt: 1_700_100_000 }, + }); expect(onSessionEvent).toHaveBeenCalledWith('started', 'deck_codex_brain', 'idle'); }); }); diff --git a/test/daemon/p2p-parser.test.ts b/test/daemon/p2p-parser.test.ts index 916fe8802..130d7ca49 100644 --- a/test/daemon/p2p-parser.test.ts +++ b/test/daemon/p2p-parser.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; // ── Hoisted mocks ───────────────────────────────────────────────────────────── @@ -534,4 +537,33 @@ describe('structured P2P routing via WS fields', () => { const [{ rounds }] = (startP2pRun as ReturnType).mock.calls[0]; expect(rounds).toBe(2); }); + + it('limits pulled file contents to the first 20 referenced files', async () => { + const dir = await mkdtemp(join(tmpdir(), 'p2p-file-limit-')); + try { + const filePaths: string[] = []; + for (let i = 0; i < 25; i++) { + const filePath = join(dir, `file-${i}.ts`); + await writeFile(filePath, `export const value${i} = ${i};\n`, 'utf8'); + filePaths.push(filePath); + } + + handleWebCommand({ + type: 'session.send', + sessionName: 'deck_proj_brain', + text: `${filePaths.map((fp) => `@${fp}`).join(' ')} review these files`, + commandId: 'cmd-file-limit', + p2pAtTargets: [{ session: 'deck_proj_w1', mode: 'review' }], + }, mockServerLink as any); + + await new Promise((r) => setTimeout(r, 100)); + + expect(startP2pRun).toHaveBeenCalledOnce(); + const [{ fileContents }] = (startP2pRun as ReturnType).mock.calls[0]; + expect(fileContents).toHaveLength(20); + expect(fileContents.map((f: { path: string }) => f.path)).toEqual(filePaths.slice(0, 20)); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); diff --git a/test/daemon/sdk-transport-restore.test.ts b/test/daemon/sdk-transport-restore.test.ts index 347554824..dfb4ecb3a 100644 --- a/test/daemon/sdk-transport-restore.test.ts +++ b/test/daemon/sdk-transport-restore.test.ts @@ -250,6 +250,49 @@ describe('sdk transport session restore', () => { expect(onSessionEvent).toHaveBeenCalledWith('started', 'deck_sdk_new_brain', 'idle'); }); + it('removes stale transport runtime from the map before awaiting a fresh kill', async () => { + await connectProvider('claude-code-sdk', {}); + await launchTransportSession({ + name: 'deck_sdk_fresh_brain', + projectName: 'sdkfresh', + role: 'brain', + agentType: 'claude-code-sdk', + projectDir: '/tmp/sdk-fresh', + requestedModel: 'sonnet', + }); + + const existingRuntime = getTransportRuntime('deck_sdk_fresh_brain'); + expect(existingRuntime).toBeDefined(); + + let releaseKill: (() => void) | null = null; + const killBarrier = new Promise((resolve) => { releaseKill = resolve; }); + const originalKill = existingRuntime!.kill.bind(existingRuntime); + const killSpy = vi.spyOn(existingRuntime!, 'kill').mockImplementation(async () => { + await killBarrier; + await originalKill(); + }); + + const relaunchPromise = launchTransportSession({ + name: 'deck_sdk_fresh_brain', + projectName: 'sdkfresh', + role: 'brain', + agentType: 'claude-code-sdk', + projectDir: '/tmp/sdk-fresh', + requestedModel: 'sonnet', + fresh: true, + ccSessionId: 'cc-session-fresh', + }); + + await flush(); + expect(getTransportRuntime('deck_sdk_fresh_brain')).toBeUndefined(); + + releaseKill?.(); + await relaunchPromise; + + expect(getTransportRuntime('deck_sdk_fresh_brain')).toBeDefined(); + killSpy.mockRestore(); + }); + it('resumes Claude conversation when switching from cli to sdk', async () => { const name = 'deck_switch_ccsdk_brain'; const record = { @@ -283,6 +326,45 @@ describe('sdk transport session restore', () => { expect(mocks.claudeRuns.at(-1)?.options.sessionId).toBeUndefined(); }); + it('relaunches claude-code-sdk with a fresh provider route key while preserving the Claude resume id', async () => { + const name = 'deck_restart_ccsdk_brain'; + const record = { + name, + projectName: 'restartccsdk', + role: 'brain', + agentType: 'claude-code-sdk', + projectDir: '/tmp/restart-ccsdk', + state: 'idle', + restarts: 0, + restartTimestamps: [], + createdAt: Date.now(), + updatedAt: Date.now(), + runtimeType: 'transport', + providerId: 'claude-code-sdk', + providerSessionId: 'route-cc-old', + ccSessionId: 'cc-session-restart', + }; + mocks.store.set(name, record); + + await connectProvider('claude-code-sdk', {}); + await relaunchSessionWithSettings(record as any, { agentType: 'claude-code-sdk' }); + + const next = mocks.store.get(name); + expect(next?.agentType).toBe('claude-code-sdk'); + expect(next?.ccSessionId).toBe('cc-session-restart'); + expect(next?.providerSessionId).toBeTruthy(); + expect(next?.providerSessionId).not.toBe('route-cc-old'); + + const runtime = getTransportRuntime(name); + expect(runtime?.providerSessionId).toBe(next?.providerSessionId); + + runtime!.send('What token did I ask you to remember?'); + await flush(); + + expect(mocks.claudeRuns.at(-1)?.options.resume).toBe('cc-session-restart'); + expect(mocks.claudeRuns.at(-1)?.options.sessionId).toBeUndefined(); + }); + it('preserves Claude resume id when switching from sdk to cli', async () => { const name = 'deck_switch_cccli_brain'; const record = { diff --git a/test/daemon/session-bootstrap.test.ts b/test/daemon/session-bootstrap.test.ts new file mode 100644 index 000000000..5f5a60456 --- /dev/null +++ b/test/daemon/session-bootstrap.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { buildWorkerSessionPersistBody, mergeWorkerSessionSnapshot } from '../../src/daemon/session-bootstrap.js'; + +describe('buildWorkerSessionPersistBody', () => { + it('serializes the current label so later daemon syncs do not wipe it', () => { + const payload = buildWorkerSessionPersistBody({ + name: 'deck_proj_brain', + projectName: 'proj', + role: 'brain', + agentType: 'codex', + projectDir: '/tmp/proj', + state: 'idle', + label: 'Readable Main', + description: 'persona', + requestedModel: 'gpt-5', + activeModel: 'gpt-5', + modelDisplay: 'GPT-5', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + } as any); + + expect(payload).toEqual(expect.objectContaining({ + projectName: 'proj', + projectRole: 'brain', + label: 'Readable Main', + requestedModel: 'gpt-5', + activeModel: 'gpt-5', + })); + }); +}); + +describe('mergeWorkerSessionSnapshot', () => { + it('hydrates the persisted main-session label from the worker snapshot', () => { + const merged = mergeWorkerSessionSnapshot(undefined, { + name: 'deck_proj_brain', + project_name: 'proj', + role: 'brain', + agent_type: 'codex', + project_dir: '/tmp/proj', + state: 'idle', + label: 'Readable Main', + }); + + expect(merged.label).toBe('Readable Main'); + expect(merged.projectName).toBe('proj'); + }); + + it('clears a stale local label when the persisted worker snapshot has no label', () => { + const merged = mergeWorkerSessionSnapshot({ + name: 'deck_proj_brain', + projectName: 'proj', + role: 'brain', + agentType: 'codex', + projectDir: '/tmp/proj', + state: 'idle', + label: 'Stale Label', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + }, { + name: 'deck_proj_brain', + project_name: 'proj', + role: 'brain', + agent_type: 'codex', + project_dir: '/tmp/proj', + state: 'idle', + label: null, + }); + + expect(merged.label).toBeUndefined(); + }); +}); diff --git a/test/daemon/transport-relay.test.ts b/test/daemon/transport-relay.test.ts index 002097241..79102921b 100644 --- a/test/daemon/transport-relay.test.ts +++ b/test/daemon/transport-relay.test.ts @@ -268,7 +268,7 @@ describe('transport-relay (timeline-emitter based)', () => { expect(textCall![2].text).toBe('part1 part2'); }); - it('emits usage.update when completion metadata includes model and usage', () => { + it('emits usage.update using current usage semantics when completion metadata includes model and usage', () => { const { provider, fireComplete } = makeMockProvider(); wireProviderToRelay(provider); @@ -276,14 +276,19 @@ describe('transport-relay (timeline-emitter based)', () => { id: 'msg-usage', metadata: { model: 'qwen3-coder-plus', - usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 5 }, + usage: { + input_tokens: 100, + output_tokens: 20, + cache_creation_input_tokens: 15, + cache_read_input_tokens: 5, + }, }, })); const usageCall = emitMock.mock.calls.find(c => c[1] === 'usage.update'); expect(usageCall).toBeDefined(); expect(usageCall![2]).toMatchObject({ - inputTokens: 100, + inputTokens: 115, cacheTokens: 5, model: 'qwen3-coder-plus', }); diff --git a/test/e2e/qwen-transport-flow.test.ts b/test/e2e/qwen-transport-flow.test.ts index cfad5e065..1d54e724b 100644 --- a/test/e2e/qwen-transport-flow.test.ts +++ b/test/e2e/qwen-transport-flow.test.ts @@ -293,4 +293,44 @@ describe('qwen transport flow e2e', () => { expect(textEvents[1]?.opts?.eventId).toBe(`transport:${SESSION}:msg-qwen-e2e-error`); expect(textEvents[1]?.payload.text).toBe('partial failure\n\n⚠️ Error: provider exploded'); }); + + it('restarts qwen by reusing the persisted provider session id instead of creating a new session', async () => { + mocks.nextUuid + .mockReturnValueOnce('11111111-1111-4111-8111-111111111111') + .mockReturnValue('22222222-2222-4222-8222-222222222222'); + + await launchSession({ + name: SESSION, + projectName: 'qwene2e', + role: 'brain', + agentType: 'qwen', + projectDir: '/tmp/qwen-e2e', + }); + + const initial = mocks.store.get(SESSION); + expect(initial?.providerSessionId).toBe('11111111-1111-4111-8111-111111111111'); + + const serverLink = { send: vi.fn() } as any; + handleWebCommand({ + type: 'session.restart', + sessionName: SESSION, + agentType: 'qwen', + }, serverLink); + await flushAsync(); + + const restarted = mocks.store.get(SESSION); + expect(restarted?.providerSessionId).toBe('11111111-1111-4111-8111-111111111111'); + + mocks.emitted.length = 0; + handleWebCommand({ + type: 'session.send', + session: SESSION, + text: 'hello after restart', + commandId: 'cmd-qwen-after-restart', + }, serverLink); + await flushAsync(); + + const final = mocks.emitted.find((e) => e.session === SESSION && e.type === 'assistant.text' && e.payload.streaming === false); + expect(final?.payload.text).toBe('Qwen: hello after restart'); + }); }); diff --git a/web/src/app.tsx b/web/src/app.tsx index 128b1791f..b96dc4971 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -60,12 +60,14 @@ import { shouldSubscribeTerminalRaw, type TerminalSubscribeViewMode } from './te import { onWatchCommand } from './watch-bridge.js'; import { watchProjectionStore } from './watch-projection.js'; import { isIdleSessionStateTimelineEvent, isRunningTimelineEvent } from './timeline-running.js'; -import { extractTransportPendingMessages } from './transport-queue.js'; +import { extractTransportPendingMessages, mergeTransportPendingMessagesForRunningState } from './transport-queue.js'; import { ingestTimelineEventForCache } from './hooks/useTimeline.js'; import { getMobileKeyboardState } from './mobile-keyboard.js'; import { pickReadableSessionDisplay } from '@shared/session-display.js'; +import { updateMainSessionLabel } from './session-label-api.js'; import { getSelectedServerName, + hasResolvedActiveSession, shouldResetSelectedServer, shouldShowInitialConnectingGate, } from './server-selection.js'; @@ -142,6 +144,25 @@ export function App() { return null; } }); + const clearAuthState = useCallback(async (reason?: string) => { + console.warn('[auth] clearing auth state', reason ?? ''); + clearApiKey(); + try { await clearAuthKey(); } catch { /* ignore */ } + try { + const { Preferences } = await import('@capacitor/preferences'); + await Preferences.remove({ key: 'deck_api_key_id' }); + } catch { /* ignore */ } + localStorage.removeItem('rcc_auth'); + localStorage.removeItem('rcc_server'); + localStorage.removeItem('rcc_server_name'); + localStorage.removeItem('rcc_session'); + setAuth(null); + setServers([]); + setServersLoaded(false); + setServersSynced(false); + setSelectedServerId(null); + setSelectedServerName(null); + }, []); // Native: server URL state and readiness flag const [nativeServerUrl, setNativeServerUrl] = useState(null); @@ -415,11 +436,9 @@ export function App() { // Registered once so any apiFetch 401 after refresh failure lands here. useEffect(() => { onAuthExpired((reason?: string) => { - console.warn('[auth] onAuthExpired fired — clearing auth state, reason:', reason); - localStorage.removeItem('rcc_auth'); - setAuth(null); + void clearAuthState(reason ?? 'expired'); }); - }, []); + }, [clearAuthState]); // Verify session via /api/auth/user/me on mount (cookie-based auth) @@ -440,9 +459,7 @@ export function App() { }).catch((err) => { console.warn(`[auth] /me FAILED:`, err instanceof ApiError ? `${err.status}: ${err.body}` : err); if (err instanceof ApiError && err.status === 401) { - console.warn('[auth] /me 401 — clearing auth (login required)'); - localStorage.removeItem('rcc_auth'); - setAuth(null); + void clearAuthState('mount_verify_401'); } }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -469,6 +486,26 @@ export function App() { return () => document.removeEventListener('visibilitychange', onVisible); }, [auth]); + useEffect(() => { + if (!auth) return; + const verifyAuthStillValid = async (reason: string) => { + try { + await fetchMe(); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + await clearAuthState(reason); + } + } + }; + const onVisible = () => { + if (document.visibilityState === 'visible') { + void verifyAuthStillValid('visibility_verify_401'); + } + }; + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); + }, [auth, clearAuthState]); + const handleRenameServer = useCallback(async (server: ServerInfo) => { const newName = prompt('Rename server:', server.name); if (!newName || newName.trim() === server.name) return; @@ -539,6 +576,17 @@ export function App() { void loadServers(); }, [loadServers]); + useEffect(() => { + if (!auth || !selectedServerId || !serversLoaded) return; + const selectedServer = servers.find((server) => server.id === selectedServerId); + if (!selectedServer || isServerOnline(selectedServer)) return; + void fetchMe().catch(async (err) => { + if (err instanceof ApiError && err.status === 401) { + await clearAuthState('server_offline_verify_401'); + } + }); + }, [auth, clearAuthState, selectedServerId, servers, serversLoaded]); + // Periodically refresh server list so lastHeartbeatAt stays current useEffect(() => { if (!auth) return; @@ -546,19 +594,6 @@ export function App() { return () => clearInterval(id); }, [auth, loadServers]); - // Rename = update project_name in D1 + local sessions state - const handleRenameSession = useCallback(async (sessionName: string, newProjectName: string) => { - if (!selectedServerId || !newProjectName) return; - // Optimistic update - setSessions((prev) => prev.map((s) => s.name === sessionName ? { ...s, project: newProjectName } : s)); - try { - await apiFetch(`/api/server/${selectedServerId}/sessions/${encodeURIComponent(sessionName)}/rename`, { - method: 'PATCH', - body: JSON.stringify({ name: newProjectName }), - }); - } catch { /* best-effort */ } - }, [selectedServerId]); - // Fetch sessions from DB immediately when auth + server are available useEffect(() => { if (!auth || !selectedServerId) return; @@ -628,6 +663,22 @@ export function App() { const lastImcodesActivityRef = useRef(Date.now()); const resubscribeTimersRef = useRef>>(new Set()); + // Rename = update main-session label in D1 + local sessions state + const handleRenameSession = useCallback(async (sessionName: string, nextLabel: string | null) => { + if (!selectedServerId) return; + const previousLabel = sessions.find((s) => s.name === sessionName)?.label ?? null; + setSessions((prev) => prev.map((s) => ( + s.name === sessionName ? { ...s, label: nextLabel } : s + ))); + try { + await updateMainSessionLabel(selectedServerId, sessionName, nextLabel); + } catch { + setSessions((prev) => prev.map((s) => ( + s.name === sessionName ? { ...s, label: previousLabel } : s + ))); + } + }, [selectedServerId, sessions]); + // IDs of currently-open (non-minimized) sub-session windows const [openSubIds, setOpenSubIds] = useState>(new Set()); @@ -1281,15 +1332,16 @@ export function App() { : s, )); } else if (liveState === 'running') { - const pendingMessages = hasPendingMessagesField - ? extractTransportPendingMessages(event.payload.pendingMessages) - : null; setSessions((prev) => prev.map((s) => s.name === event.sessionId ? { ...s, state: 'running' as SessionInfo['state'], - transportPendingMessages: pendingMessages ?? (s.transportPendingMessages ?? []), + transportPendingMessages: mergeTransportPendingMessagesForRunningState( + s.transportPendingMessages, + event.payload.pendingMessages, + hasPendingMessagesField, + ), } : s, )); @@ -1547,6 +1599,9 @@ export function App() { } } if (msg.type === DAEMON_MSG.UPGRADE_BLOCKED) { + const message = msg.reason === 'transport_busy' + ? trans('toast.upgrade_blocked_transport_busy') + : trans('toast.upgrade_blocked_p2p_active'); const id = Date.now() + Math.random(); setToasts((prev) => [...prev, { id, @@ -1554,7 +1609,7 @@ export function App() { project: '', kind: 'notification', title: trans('toast.upgrade_blocked_title'), - message: trans('toast.upgrade_blocked_p2p_active'), + message, }]); setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== id)), 8000); } @@ -2242,8 +2297,8 @@ export function App() { selectedServerId, connected, sessionsLoaded, - serversLoaded, ); + const resolvedActiveSessionExists = hasResolvedActiveSession(activeSession, sessions); useEffect(() => { if (showInitialConnectingGate) { @@ -2555,7 +2610,7 @@ export function App() { /> {/* Desktop local preview shortcut — available even before a session is active */} - {!isMobile && selectedServerId && !activeSession && ( + {!isMobile && selectedServerId && !resolvedActiveSessionExists && (
) )} - {last && } + {last && } {expanded && middle.length > 0 && (
); @@ -1143,11 +1183,11 @@ const ChatEvent = memo(function ChatEvent({ event, nextTs, onPathClick, serverId
{'>'} {String(event.payload.tool ?? 'tool')} - {toolInput && {' '}{splitPathsAndUrls(toolInput, onPathClick)}} + {toolInput && {' '}{splitPathsAndUrls(toolInput, onPathClick, undefined, onDownload)}}
{toolOutput && (
- {splitPathsAndUrls(toolOutput, onPathClick)} + {splitPathsAndUrls(toolOutput, onPathClick, undefined, onDownload)}
)} {(callDetail || resultDetail) && ( @@ -1173,9 +1213,9 @@ const ChatEvent = memo(function ChatEvent({ event, nextTs, onPathClick, serverId
{'<'} {error ? ( - {`error: ${String(error)}`} - ) : output ? ( - {splitPathsAndUrls(output, onPathClick)} + {`error: ${String(error)}`} + ) : output ? ( + {splitPathsAndUrls(output, onPathClick, undefined, onDownload)} ) : ( done )} @@ -1293,18 +1333,19 @@ const ChatTime = memo(function ChatTime({ ts }: { ts: number }) { // ── Markdown rendering delegated to ChatMarkdown.tsx ────────────────────── // ── URL detection (must run BEFORE path detection) ──────────────────────── -const URL_REGEX = /https?:\/\/[^\s<>"\])}]+/g; +const URL_REGEX = /https?:\/\/[^\s<>"\])})】》」』,。;:!?(【《「『]+/g; // Matches absolute paths (/foo/bar) and relative paths (docs/file.md, src/components/Foo.tsx). -const PATH_REGEX = /(\.{1,2}\/[\w\p{L}.\-~/]+|\/[\w\p{L}.\-~][\w\p{L}.\-~/]*|(? void, onUrlClick?: (url: string) => void, + onDownload?: (path: string) => void, ): h.JSX.Element[] { - if (!onPathClick && !onUrlClick) return [{text}]; + if (!onPathClick && !onUrlClick && !onDownload) return [{text}]; // Step 1: Split by URLs first (URLs take priority over path detection) const parts: preact.JSX.Element[] = []; @@ -1318,8 +1359,7 @@ function splitPathsAndUrls( while ((m = URL_REGEX.exec(text)) !== null) { if (m.index > last) chunks.push({ type: 'text', value: text.slice(last, m.index), start: last }); // Strip trailing punctuation that likely isn't part of the URL - let url = m[0]; - while (url.length > 1 && /[.,;:!?)}\]>]$/.test(url)) url = url.slice(0, -1); + let url = trimDetectedUrl(m[0]); chunks.push({ type: 'url', value: url, start: m.index }); last = m.index + url.length; URL_REGEX.lastIndex = last; // adjust for stripped chars @@ -1351,15 +1391,29 @@ function splitPathsAndUrls( while ((pm = PATH_REGEX.exec(chunk.value)) !== null) { const path = pm[1]; if (path.length < 3) continue; + if (isLikelyDomainPath(path)) continue; if (pm.index > pathLast) parts.push({chunk.value.slice(pathLast, pm.index)}); parts.push( - onPathClick(path)} - title={path} - > - {path} + + onPathClick(path)} + title={path} + > + {path} + + {onDownload && hasFileExtension(path) && ( + + )} , ); pathLast = pm.index + pm[0].length; diff --git a/web/src/components/FileBrowser.tsx b/web/src/components/FileBrowser.tsx index ef1638ddf..4b28ed5e6 100644 --- a/web/src/components/FileBrowser.tsx +++ b/web/src/components/FileBrowser.tsx @@ -415,6 +415,7 @@ export function FileBrowser({ const pendingMkdirRef = useRef(new Map()); const mountedRef = useRef(true); const dismissedAutoPreviewPathRef = useRef(null); + const previewTabOverridePathRef = useRef(null); const nextPreviewCycleIdRef = useRef(1); const activePreviewCycleRef = useRef(null); @@ -701,6 +702,7 @@ export function FileBrowser({ return; } dismissedAutoPreviewPathRef.current = null; + previewTabOverridePathRef.current = null; setEditDirty(false); setEditContent(''); setOriginalMtime(undefined); @@ -843,7 +845,9 @@ export function FileBrowser({ if (dismissedAutoPreviewPathRef.current === autoPreviewPath && preview.status === 'idle') return; const currentPreviewPath = preview.status !== 'idle' ? (preview as { path: string }).path : null; if (currentPreviewPath === autoPreviewPath && preview.status !== 'idle') { - setShowDiff(autoPreviewPreferDiff); + if (previewTabOverridePathRef.current !== autoPreviewPath) { + setShowDiff(autoPreviewPreferDiff); + } if (preview.status === 'loading' && initialPreview?.status === 'loading' && !skipAutoPreviewIfLoading) { const hasPendingRead = hasPendingPreviewWork('read', autoPreviewPath); if (!hasPendingRead) fetchPreview(autoPreviewPath, autoPreviewPreferDiff); @@ -856,6 +860,7 @@ export function FileBrowser({ const dismissPreview = useCallback(() => { if (editDirty && !window.confirm(t('fileBrowser.unsavedChanges'))) return; if (autoPreviewPath) dismissedAutoPreviewPathRef.current = autoPreviewPath; + previewTabOverridePathRef.current = null; activePreviewCycleRef.current = null; setIsEditing(false); setEditDirty(false); @@ -1064,7 +1069,10 @@ export function FileBrowser({ {!isEditing && hasDiff && (
{/* Usage footer — shared component */} - {(lastUsage || activeThinkingTs || statusText || sessionInfo?.planLabel || sessionInfo?.quotaLabel || sessionInfo?.quotaUsageLabel) && ( + {(lastUsage || activeThinkingTs || statusText || liveSessionState === 'running' || liveSessionState === 'idle' || sessionInfo?.planLabel || sessionInfo?.quotaLabel || sessionInfo?.quotaUsageLabel || sessionInfo?.quotaMeta) && ( )} diff --git a/web/src/components/UsageFooter.tsx b/web/src/components/UsageFooter.tsx index 5e869bbd8..a9bb9a3ae 100644 --- a/web/src/components/UsageFooter.tsx +++ b/web/src/components/UsageFooter.tsx @@ -26,6 +26,8 @@ interface Props { activeThinkingTs?: number | null; /** Status text from agent (e.g. "Reading file..."). */ statusText?: string | null; + /** Whether the current live tail is an active tool call. */ + activeToolCall?: boolean; /** Current timestamp for thinking timer (updated every second). */ now?: number; } @@ -35,10 +37,11 @@ const fmt = (n: number) => : n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n); -export function UsageFooter({ usage, sessionName, sessionState, agentType, modelOverride, planLabel, quotaLabel, quotaUsageLabel, quotaMeta, showCost, activeThinkingTs, statusText, now }: Props) { +export function UsageFooter({ usage, sessionName, sessionState, agentType, modelOverride, planLabel, quotaLabel, quotaUsageLabel, quotaMeta, showCost, activeThinkingTs, statusText, activeToolCall, now }: Props) { const { t } = useTranslation(); const isCodexFamily = agentType === 'codex' || agentType === 'codex-sdk'; - const showRunningStatus = sessionState === 'running' && !!(activeThinkingTs || statusText); + const hasActiveLiveWork = !!activeToolCall || !!activeThinkingTs; + const showLiveStatus = sessionState === 'running' || sessionState === 'idle' || hasActiveLiveWork; const [quotaNow, setQuotaNow] = useState(() => Date.now()); const displayModel = modelOverride ?? usage.model; @@ -95,6 +98,21 @@ export function UsageFooter({ usage, sessionName, sessionState, agentType, model const monthlyCost = sessionCost > 0 ? getMonthlyCost() : 0; const modelLabel = shortModelLabel(displayModel); const inlineQuotaText = displayQuotaLabel; + const liveStatusMode = hasActiveLiveWork + ? (activeToolCall ? 'tool' : 'thinking') + : sessionState === 'running' + ? 'running' + : sessionState === 'idle' ? 'idle' : null; + const liveStatusText = useMemo(() => { + if (hasActiveLiveWork || sessionState === 'running') { + if (activeToolCall) return statusText || 'Tool running...'; + if (activeThinkingTs) return t('chat.thinking_running', { sec: Math.max(0, Math.round(((now ?? Date.now()) - activeThinkingTs) / 1000)) }); + return 'Agent working...'; + } + if (sessionState === 'idle') return 'Agent idle — waiting for input'; + return null; + }, [activeThinkingTs, activeToolCall, hasActiveLiveWork, now, sessionState, statusText, t]); + const showInlineStatusText = liveStatusMode === 'running' || liveStatusMode === 'thinking' || liveStatusMode === 'tool'; const codexQuotaLines = (agentType === 'codex' || agentType === 'codex-sdk') ? (displayQuotaLabel ?? '').split(' · ').filter(Boolean) : []; @@ -115,12 +133,14 @@ export function UsageFooter({ usage, sessionName, sessionState, agentType, model )}
- {showRunningStatus && ( - - ··· - {' '}{activeThinkingTs - ? t('chat.thinking_running', { sec: Math.max(0, Math.round(((now ?? Date.now()) - activeThinkingTs) / 1000)) }) - : statusText} + {showLiveStatus && liveStatusText && liveStatusMode && ( + + 🤖 + {liveStatusMode === 'running' && ⚙️} + {liveStatusMode === 'thinking' && 💭} + {liveStatusMode === 'tool' && 🔍} + {liveStatusMode === 'idle' && 💤} + {showInlineStatusText && {liveStatusText}} )} diff --git a/web/src/components/pinnedPanelTypes.tsx b/web/src/components/pinnedPanelTypes.tsx index 8c3ac8baf..63caf526d 100644 --- a/web/src/components/pinnedPanelTypes.tsx +++ b/web/src/components/pinnedPanelTypes.tsx @@ -14,7 +14,8 @@ import { useMemo } from 'preact/hooks'; import { useTranslation } from 'react-i18next'; import { UsageFooter } from './UsageFooter.js'; import { extractLatestUsage } from '../usage-data.js'; -import { getActiveThinkingTs, getActiveStatusText } from '../thinking-utils.js'; +import { getActiveThinkingTs, getActiveStatusText, getTailSessionState, hasActiveToolCall } from '../thinking-utils.js'; +import { useNowTicker } from '../hooks/useNowTicker.js'; import type { PinnedPanel } from '../app.js'; import type { PanelRenderContext } from './PinnedPanelRegistry.js'; @@ -37,6 +38,12 @@ function SubSessionContent({ panel, ctx }: { panel: PinnedPanel; ctx: PanelRende const lastUsage = useMemo(() => extractLatestUsage(events), [events]); const activeThinkingTs = useMemo(() => getActiveThinkingTs(events), [events]); const statusText = useMemo(() => getActiveStatusText(events), [events]); + const activeToolCall = useMemo(() => hasActiveToolCall(events), [events]); + const liveSessionState = useMemo( + () => getTailSessionState(events) ?? liveSub?.state ?? null, + [events, liveSub?.state], + ); + const thinkingNow = useNowTicker(!!activeThinkingTs); if (!liveSub) { return ; @@ -45,11 +52,9 @@ function SubSessionContent({ panel, ctx }: { panel: PinnedPanel; ctx: PanelRende const isShell = liveSub.type === 'shell' || liveSub.type === 'script'; const mode = pinnedViewMode ?? (isShell ? 'terminal' : 'chat'); const modelDisplay = liveSub.modelDisplay ?? (liveSub.type === 'qwen' ? liveSub.qwenModel : undefined); - const compactQuotaText = liveSub.type === 'codex' + const compactQuotaText = liveSub.type === 'codex' || liveSub.type === 'codex-sdk' ? '' - : liveSub.type === 'codex-sdk' - ? (liveSub.quotaLabel ?? '') - : [liveSub.quotaLabel, liveSub.quotaUsageLabel].filter(Boolean).join(' · '); + : [liveSub.quotaLabel, liveSub.quotaUsageLabel].filter(Boolean).join(' · '); return ( <> @@ -61,18 +66,18 @@ function SubSessionContent({ panel, ctx }: { panel: PinnedPanel; ctx: PanelRende loading={false} refreshing={refreshing} sessionId={sessionName} - sessionState={liveSub.state} + sessionState={liveSessionState ?? undefined} ws={ctx.ws} workdir={liveSub.cwd ?? null} serverId={ctx.serverId} onQuote={ctx.onQuote} /> )} - {(lastUsage || activeThinkingTs || statusText || liveSub.planLabel || liveSub.quotaLabel || liveSub.quotaUsageLabel) && ( + {(lastUsage || activeThinkingTs || statusText || liveSessionState === 'running' || liveSessionState === 'idle' || liveSub.planLabel || liveSub.quotaLabel || liveSub.quotaUsageLabel || liveSub.quotaMeta) && ( )} {(compactQuotaText || liveSub.planLabel) && ( diff --git a/web/src/hooks/useSubSessions.ts b/web/src/hooks/useSubSessions.ts index 9475ed1a3..4b97c09d1 100644 --- a/web/src/hooks/useSubSessions.ts +++ b/web/src/hooks/useSubSessions.ts @@ -11,7 +11,7 @@ import { } from '../api.js'; import type { WsClient } from '../ws-client.js'; import { isRunningTimelineEvent } from '../timeline-running.js'; -import { extractTransportPendingMessages } from '../transport-queue.js'; +import { extractTransportPendingMessages, mergeTransportPendingMessagesForRunningState } from '../transport-queue.js'; export interface SubSession extends SubSessionData { sessionName: string; @@ -252,12 +252,19 @@ export function useSubSessions( return; } if (state === 'running' && hasPendingMessagesField) { - const pendingMessages = extractTransportPendingMessages(msg.event.payload.pendingMessages); setSubSessions((prev) => { const idx = prev.findIndex((s) => s.sessionName === sessionName); if (idx === -1) return prev; const next = [...prev]; - next[idx] = { ...next[idx], state: 'running', transportPendingMessages: pendingMessages }; + next[idx] = { + ...next[idx], + state: 'running', + transportPendingMessages: mergeTransportPendingMessagesForRunningState( + next[idx].transportPendingMessages, + msg.event.payload.pendingMessages, + true, + ), + }; return next; }); return; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5943ad740..24bc2e366 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -182,7 +182,8 @@ "toast": { "finished": "finished", "upgrade_blocked_title": "Upgrade blocked", - "upgrade_blocked_p2p_active": "P2P is still running. Stop it before upgrading the daemon." + "upgrade_blocked_p2p_active": "P2P is still running. Stop it before upgrading the daemon.", + "upgrade_blocked_transport_busy": "A transport session is still in a turn. Wait until it goes idle before upgrading the daemon." }, "discussion": { "role_critic": "Critic", @@ -476,12 +477,12 @@ "propose_action": "Propose", "propose_from_discussion_action": "From Recent Discussion", "propose_from_description_action": "From Description Below", - "audit_implementation_prompt": "Perform a strict implementation audit for {{reference}} against its OpenSpec artifacts. Identify every mismatch, omission, regression risk, edge-case gap, and missing test; then fix the code, tests, and any required supporting changes. Do not stop at a report. Deliver the implementation in full spec compliance.", - "audit_spec_prompt": "Perform a strict specification audit for {{reference}}. Strengthen scope, requirements, acceptance criteria, edge cases, failure modes, dependencies, and testability; remove ambiguity and internal inconsistency; and update the spec until it is implementation-ready. Do not stop at review notes.", - "implement_prompt": "Drive the implementation of {{reference}} aggressively. Break the work into concrete sub-tasks, dispatch sub-agents with clear ownership, integrate their output, resolve gaps against the spec, add or update tests, and verify the finished result. You own orchestration, technical decisions, and final acceptance.", - "achieve_prompt": "Take {{reference}} to done using the full OpenSpec workflow. Inspect all remaining tasks and artifacts, complete the required implementation and spec work, close outstanding gaps, sync affected specs if needed, and archive the change once it meets completion criteria. Do not stop at status reporting.", - "propose_from_discussion_prompt": "Generate an OpenSpec proposal from the recent discussion. Extract the goal, scope, key requirements, and acceptance criteria, and list unclear points as follow-ups. Start with a change proposal draft that is ready to land.", - "propose_from_description_prompt": "Generate an OpenSpec proposal from the description below. Organize the goal, scope, key requirements, and acceptance criteria, and list missing details as follow-ups. Start with a change proposal draft that is ready to land." + "audit_implementation_prompt": "Perform a strict implementation audit for {{reference}} against its OpenSpec artifacts. Identify every mismatch, omission, regression risk, edge-case gap, and missing test; then fix the code, tests, and any required supporting changes. If the change artifacts need to move with the implementation, update the relevant OpenSpec files under {{reference}} in the same task. Do not stop at a report. Deliver the implementation in full spec compliance.", + "audit_spec_prompt": "Perform a strict specification audit for {{reference}}. Strengthen scope, requirements, acceptance criteria, edge cases, failure modes, dependencies, and testability; remove ambiguity and internal inconsistency; then directly update the change artifacts under {{reference}} (proposal, design, specs, tasks) until they are implementation-ready. Do not stop at review notes.", + "implement_prompt": "Drive the implementation of {{reference}} aggressively. Break the work into concrete sub-tasks, dispatch sub-agents with clear ownership, integrate their output, resolve gaps against the spec, add or update tests, and verify the finished result. Keep the OpenSpec artifacts under {{reference}} aligned with the implementation as you go instead of leaving follow-up notes. You own orchestration, technical decisions, and final acceptance.", + "achieve_prompt": "Take {{reference}} to done using the full OpenSpec workflow. Inspect all remaining tasks and artifacts, complete the required implementation and spec work, directly update proposal/design/specs/tasks under {{reference}} where needed, close outstanding gaps, sync affected specs if needed, and archive the change once it meets completion criteria. Do not stop at status reporting.", + "propose_from_discussion_prompt": "Generate an OpenSpec change from the recent discussion. Extract the goal, scope, key requirements, and acceptance criteria, list unclear points as follow-ups, and write the actual change artifacts under openspec/changes/ (proposal, design, specs, tasks) instead of stopping at a draft note.", + "propose_from_description_prompt": "Generate an OpenSpec change from the description below. Organize the goal, scope, key requirements, and acceptance criteria, list missing details as follow-ups, and write the actual change artifacts under openspec/changes/ (proposal, design, specs, tasks) instead of stopping at a draft note." }, "upload": { "upload_file": "Upload file", diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index 52e5ab6b2..7a767f035 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -476,12 +476,12 @@ "propose_action": "Proponer", "propose_from_discussion_action": "Desde la discusión reciente", "propose_from_description_action": "Desde la descripción de abajo", - "audit_implementation_prompt": "Realiza una auditoría estricta de la implementación de {{reference}} contra sus artefactos de OpenSpec. Identifica cada discrepancia, omisión, riesgo de regresión, hueco en casos límite y prueba faltante; después corrige el código, las pruebas y cualquier cambio de soporte necesario. No te detengas en un informe: deja la implementación totalmente alineada con la especificación.", - "audit_spec_prompt": "Realiza una auditoría estricta de la especificación de {{reference}}. Refuerza alcance, requisitos, criterios de aceptación, casos límite, modos de fallo, dependencias y capacidad de prueba; elimina ambigüedades e inconsistencias internas; y actualiza la especificación hasta que quede lista para implementar. No te limites a observaciones de revisión.", - "implement_prompt": "Impulsa con firmeza la implementación de {{reference}}. Divide el trabajo en subtareas concretas, asigna subagentes con propiedad clara, integra sus resultados, cierra las brechas respecto a la especificación, añade o actualiza pruebas y verifica el resultado final. Tú eres responsable de la orquestación, las decisiones técnicas y la aceptación final.", - "achieve_prompt": "Lleva {{reference}} hasta completarlo usando el flujo completo de OpenSpec. Revisa todas las tareas y artefactos pendientes, completa el trabajo necesario de implementación y especificación, cierra los huecos abiertos, sincroniza las especificaciones afectadas si hace falta y archiva el cambio cuando cumpla los criterios de finalización. No te quedes en el reporte de estado.", - "propose_from_discussion_prompt": "Genera una propuesta de OpenSpec a partir de la discusión reciente. Extrae el objetivo, el alcance, los requisitos clave y los criterios de aceptación, y deja los puntos poco claros como pendientes. Empieza con un borrador de change proposal listo para guardar.", - "propose_from_description_prompt": "Genera una propuesta de OpenSpec a partir de la descripción de abajo. Organiza el objetivo, el alcance, los requisitos clave y los criterios de aceptación, y deja los detalles faltantes como pendientes. Empieza con un borrador de change proposal listo para guardar." + "audit_implementation_prompt": "Realiza una auditoría estricta de la implementación de {{reference}} contra sus artefactos de OpenSpec. Identifica cada discrepancia, omisión, riesgo de regresión, hueco en casos límite y prueba faltante; después corrige el código, las pruebas y cualquier cambio de soporte necesario. Si la implementación exige mover también la especificación, actualiza en la misma tarea los archivos de OpenSpec bajo {{reference}}. No te detengas en un informe: deja la implementación totalmente alineada con la especificación.", + "audit_spec_prompt": "Realiza una auditoría estricta de la especificación de {{reference}}. Refuerza alcance, requisitos, criterios de aceptación, casos límite, modos de fallo, dependencias y capacidad de prueba; elimina ambigüedades e inconsistencias internas; y actualiza directamente proposal, design, specs y tasks bajo {{reference}} hasta que quede lista para implementar. No te limites a observaciones de revisión.", + "implement_prompt": "Impulsa con firmeza la implementación de {{reference}}. Divide el trabajo en subtareas concretas, asigna subagentes con propiedad clara, integra sus resultados, cierra las brechas respecto a la especificación, añade o actualiza pruebas y verifica el resultado final. Mantén alineados durante el trabajo los artefactos de OpenSpec bajo {{reference}} en lugar de dejar notas para después. Tú eres responsable de la orquestación, las decisiones técnicas y la aceptación final.", + "achieve_prompt": "Lleva {{reference}} hasta completarlo usando el flujo completo de OpenSpec. Revisa todas las tareas y artefactos pendientes, completa el trabajo necesario de implementación y especificación, actualiza directamente proposal, design, specs y tasks bajo {{reference}} cuando haga falta, cierra los huecos abiertos, sincroniza las especificaciones afectadas si hace falta y archiva el cambio cuando cumpla los criterios de finalización. No te quedes en el reporte de estado.", + "propose_from_discussion_prompt": "Genera un cambio de OpenSpec a partir de la discusión reciente. Extrae el objetivo, el alcance, los requisitos clave y los criterios de aceptación, deja los puntos poco claros como pendientes y escribe los artefactos reales bajo openspec/changes/ (proposal, design, specs y tasks) en lugar de quedarte en una nota borrador.", + "propose_from_description_prompt": "Genera un cambio de OpenSpec a partir de la descripción de abajo. Organiza el objetivo, el alcance, los requisitos clave y los criterios de aceptación, deja los detalles faltantes como pendientes y escribe los artefactos reales bajo openspec/changes/ (proposal, design, specs y tasks) en lugar de quedarte en una nota borrador." }, "upload": { "upload_file": "Subir archivo", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 64b3847c8..c0bf48dc0 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -476,12 +476,12 @@ "propose_action": "提案", "propose_from_discussion_action": "直近の議論から生成", "propose_from_description_action": "下の説明から生成", - "audit_implementation_prompt": "{{reference}} について、OpenSpec の成果物に照らした厳格な実装監査を実施してください。不一致、抜け漏れ、回帰リスク、境界ケースの欠落、未整備のテストをすべて洗い出し、そのうえでコード、テスト、必要な関連修正まで直してください。レポートだけで終わらせず、実装を仕様完全準拠まで持っていってください。", - "audit_spec_prompt": "{{reference}} について、厳格な仕様監査を実施してください。スコープ、要件、受け入れ条件、境界ケース、失敗モード、依存関係、テスト容易性を強化し、曖昧さと内部矛盾を除去して、実装可能な品質まで仕様を更新してください。レビューコメントだけで終わらせないでください。", - "implement_prompt": "{{reference}} の実装を強力に前進させてください。作業を具体的なサブタスクに分解し、サブエージェントへ明確な責務で割り当て、成果を統合し、仕様との差分を解消し、テストを追加または更新し、最終結果を検証してください。あなたがオーケストレーション、技術判断、最終受け入れを担ってください。", - "achieve_prompt": "完全な OpenSpec ワークフローで {{reference}} を done まで持っていってください。残っているタスクと成果物をすべて確認し、必要な実装と仕様の作業を完了し、未解決事項を閉じ、必要なら影響するメイン仕様を同期し、完了条件を満たしたら変更をアーカイブしてください。進捗報告だけで止まらないでください。", - "propose_from_discussion_prompt": "直近の議論から OpenSpec proposal を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不明点は確認事項として明示してください。まずはそのまま保存できる change proposal の草案を出してください。", - "propose_from_description_prompt": "下の説明から OpenSpec proposal を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不足情報は確認事項として明示してください。まずはそのまま保存できる change proposal の草案を出してください。" + "audit_implementation_prompt": "{{reference}} について、OpenSpec の成果物に照らした厳格な実装監査を実施してください。不一致、抜け漏れ、回帰リスク、境界ケースの欠落、未整備のテストをすべて洗い出し、そのうえでコード、テスト、必要な関連修正まで直してください。実装に合わせて仕様成果物も動かす必要がある場合は、同じ作業の中で {{reference}} 配下の OpenSpec ファイルも直接更新してください。レポートだけで終わらせず、実装を仕様完全準拠まで持っていってください。", + "audit_spec_prompt": "{{reference}} について、厳格な仕様監査を実施してください。スコープ、要件、受け入れ条件、境界ケース、失敗モード、依存関係、テスト容易性を強化し、曖昧さと内部矛盾を除去したうえで、{{reference}} 配下の proposal・design・specs・tasks を直接更新し、実装可能な品質まで仕様を仕上げてください。レビューコメントだけで終わらせないでください。", + "implement_prompt": "{{reference}} の実装を強力に前進させてください。作業を具体的なサブタスクに分解し、サブエージェントへ明確な責務で割り当て、成果を統合し、仕様との差分を解消し、テストを追加または更新し、最終結果を検証してください。作業中は {{reference}} 配下の OpenSpec 成果物も同期させ、後続メモに先送りしないでください。あなたがオーケストレーション、技術判断、最終受け入れを担ってください。", + "achieve_prompt": "完全な OpenSpec ワークフローで {{reference}} を done まで持っていってください。残っているタスクと成果物をすべて確認し、必要な実装と仕様の作業を完了し、必要に応じて {{reference}} 配下の proposal・design・specs・tasks を直接更新し、未解決事項を閉じ、必要なら影響するメイン仕様を同期し、完了条件を満たしたら変更をアーカイブしてください。進捗報告だけで止まらないでください。", + "propose_from_discussion_prompt": "直近の議論から OpenSpec 変更を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不明点は確認事項として明示したうえで、draft note で止めずに openspec/changes/ 配下へ proposal・design・specs・tasks を実際に書き出してください。", + "propose_from_description_prompt": "下の説明から OpenSpec 変更を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不足情報は確認事項として明示したうえで、draft note で止めずに openspec/changes/ 配下へ proposal・design・specs・tasks を実際に書き出してください。" }, "upload": { "upload_file": "ファイルをアップロード", diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index c2bf23f67..cde74b344 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -476,12 +476,12 @@ "propose_action": "제안", "propose_from_discussion_action": "최근 논의에서 생성", "propose_from_description_action": "아래 설명에서 생성", - "audit_implementation_prompt": "{{reference}}에 대해 OpenSpec 산출물을 기준으로 엄격한 구현 감사를 수행하세요. 모든 불일치, 누락, 회귀 위험, 경계 사례 공백, 누락된 테스트를 찾아낸 뒤 코드, 테스트, 필요한 보조 변경까지 직접 수정하세요. 보고서로 끝내지 말고 구현을 명세와 완전히 일치시키세요.", - "audit_spec_prompt": "{{reference}}에 대해 엄격한 명세 감사를 수행하세요. 범위, 요구사항, 승인 기준, 경계 사례, 실패 모드, 의존성, 테스트 가능성을 강화하고, 모호함과 내부 불일치를 제거해 바로 구현 가능한 수준까지 명세를 개선하세요. 검토 의견만 남기지 말고 명세 자체를 고치세요.", - "implement_prompt": "{{reference}} 구현을 강하게 밀어붙이세요. 작업을 구체적 하위 작업으로 분해하고, 하위 에이전트에 명확한 소유권을 배정하고, 결과를 통합하고, 명세 대비 부족분을 메우고, 테스트를 추가 또는 갱신하고, 최종 결과를 검증하세요. 당신이 오케스트레이션, 기술 판단, 최종 검수를 책임지세요.", - "achieve_prompt": "전체 OpenSpec 워크플로로 {{reference}}를 완료 상태까지 밀어붙이세요. 남은 작업과 산출물을 모두 점검하고, 필요한 구현과 명세 작업을 끝내고, 미해결 항목을 닫고, 필요하면 영향받는 메인 명세를 동기화한 뒤, 완료 기준을 충족하면 변경을 보관하세요. 상태 보고에서 멈추지 마세요.", - "propose_from_discussion_prompt": "최근 논의를 바탕으로 OpenSpec proposal을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 불명확한 부분은 확인 필요 항목으로 남기세요. 먼저 바로 저장할 수 있는 change proposal 초안을 작성하세요.", - "propose_from_description_prompt": "아래 설명을 바탕으로 OpenSpec proposal을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 빠진 정보는 확인 필요 항목으로 남기세요. 먼저 바로 저장할 수 있는 change proposal 초안을 작성하세요." + "audit_implementation_prompt": "{{reference}}에 대해 OpenSpec 산출물을 기준으로 엄격한 구현 감사를 수행하세요. 모든 불일치, 누락, 회귀 위험, 경계 사례 공백, 누락된 테스트를 찾아낸 뒤 코드, 테스트, 필요한 보조 변경까지 직접 수정하세요. 구현에 맞춰 명세 산출물도 움직여야 한다면 같은 작업 안에서 {{reference}} 아래 OpenSpec 파일까지 직접 갱신하세요. 보고서로 끝내지 말고 구현을 명세와 완전히 일치시키세요.", + "audit_spec_prompt": "{{reference}}에 대해 엄격한 명세 감사를 수행하세요. 범위, 요구사항, 승인 기준, 경계 사례, 실패 모드, 의존성, 테스트 가능성을 강화하고, 모호함과 내부 불일치를 제거한 뒤 {{reference}} 아래 proposal, design, specs, tasks를 직접 갱신해 바로 구현 가능한 수준까지 명세를 개선하세요. 검토 의견만 남기지 말고 명세 자체를 고치세요.", + "implement_prompt": "{{reference}} 구현을 강하게 밀어붙이세요. 작업을 구체적 하위 작업으로 분해하고, 하위 에이전트에 명확한 소유권을 배정하고, 결과를 통합하고, 명세 대비 부족분을 메우고, 테스트를 추가 또는 갱신하고, 최종 결과를 검증하세요. 작업 중에는 {{reference}} 아래 OpenSpec 산출물도 함께 맞춰 두고 후속 메모로 미루지 마세요. 당신이 오케스트레이션, 기술 판단, 최종 검수를 책임지세요.", + "achieve_prompt": "전체 OpenSpec 워크플로로 {{reference}}를 완료 상태까지 밀어붙이세요. 남은 작업과 산출물을 모두 점검하고, 필요한 구현과 명세 작업을 끝내고, 필요하면 {{reference}} 아래 proposal, design, specs, tasks를 직접 갱신하고, 미해결 항목을 닫고, 영향받는 메인 명세를 동기화한 뒤, 완료 기준을 충족하면 변경을 보관하세요. 상태 보고에서 멈추지 마세요.", + "propose_from_discussion_prompt": "최근 논의를 바탕으로 OpenSpec 변경을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 불명확한 부분은 확인 필요 항목으로 남긴 뒤, 초안 메모에서 멈추지 말고 openspec/changes/ 아래에 proposal, design, specs, tasks를 실제로 작성하세요.", + "propose_from_description_prompt": "아래 설명을 바탕으로 OpenSpec 변경을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 빠진 정보는 확인 필요 항목으로 남긴 뒤, 초안 메모에서 멈추지 말고 openspec/changes/ 아래에 proposal, design, specs, tasks를 실제로 작성하세요." }, "upload": { "upload_file": "파일 업로드", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index f2b48d19e..7921d0fca 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -476,12 +476,12 @@ "propose_action": "Предложить", "propose_from_discussion_action": "Из недавнего обсуждения", "propose_from_description_action": "Из описания ниже", - "audit_implementation_prompt": "Проведи строгий аудит реализации {{reference}} по его артефактам OpenSpec. Выяви каждое расхождение, упущение, риск регрессии, пробел в пограничных сценариях и недостающий тест; затем исправь код, тесты и все необходимые сопутствующие изменения. Не останавливайся на отчете: доведи реализацию до полного соответствия спецификации.", - "audit_spec_prompt": "Проведи строгий аудит спецификации {{reference}}. Усиль границы задачи, требования, критерии приемки, пограничные случаи, режимы отказа, зависимости и проверяемость; устрани неоднозначность и внутренние противоречия; и обнови спецификацию до состояния, готового к реализации. Не ограничивайся замечаниями ревью.", - "implement_prompt": "Жестко доведи реализацию {{reference}}. Разбей работу на конкретные подзадачи, раздай подагентам четкие зоны ответственности, интегрируй их результат, закрой разрывы относительно спецификации, добавь или обнови тесты и проверь финальный результат. На тебе оркестрация, технические решения и итоговая приемка.", - "achieve_prompt": "Доведи {{reference}} до состояния done по полному процессу OpenSpec. Проверь все оставшиеся задачи и артефакты, выполни необходимую работу по реализации и спецификации, закрой незавершенные пункты, при необходимости синхронизируй затронутые основные спецификации и архивируй изменение, когда оно будет соответствовать критериям завершения. Не останавливайся на отчете о статусе.", - "propose_from_discussion_prompt": "Сгенерируй OpenSpec proposal на основе недавнего обсуждения. Выдели цель, scope, ключевые требования и критерии приемки, а неясные места перечисли как вопросы на уточнение. Начни с черновика change proposal, готового к сохранению.", - "propose_from_description_prompt": "Сгенерируй OpenSpec proposal на основе описания ниже. Сформируй цель, scope, ключевые требования и критерии приемки, а недостающие детали перечисли как вопросы на уточнение. Начни с черновика change proposal, готового к сохранению." + "audit_implementation_prompt": "Проведи строгий аудит реализации {{reference}} по его артефактам OpenSpec. Выяви каждое расхождение, упущение, риск регрессии, пробел в пограничных сценариях и недостающий тест; затем исправь код, тесты и все необходимые сопутствующие изменения. Если по ходу нужно синхронизировать спецификацию, обнови в той же задаче соответствующие файлы OpenSpec внутри {{reference}}. Не останавливайся на отчете: доведи реализацию до полного соответствия спецификации.", + "audit_spec_prompt": "Проведи строгий аудит спецификации {{reference}}. Усиль границы задачи, требования, критерии приемки, пограничные случаи, режимы отказа, зависимости и проверяемость; устрани неоднозначность и внутренние противоречия; затем напрямую обнови proposal, design, specs и tasks внутри {{reference}} до состояния, готового к реализации. Не ограничивайся замечаниями ревью.", + "implement_prompt": "Жестко доведи реализацию {{reference}}. Разбей работу на конкретные подзадачи, раздай подагентам четкие зоны ответственности, интегрируй их результат, закрой разрывы относительно спецификации, добавь или обнови тесты и проверь финальный результат. По ходу работы держи артефакты OpenSpec внутри {{reference}} синхронизированными, а не оставляй это как последующую заметку. На тебе оркестрация, технические решения и итоговая приемка.", + "achieve_prompt": "Доведи {{reference}} до состояния done по полному процессу OpenSpec. Проверь все оставшиеся задачи и артефакты, выполни необходимую работу по реализации и спецификации, при необходимости напрямую обнови proposal, design, specs и tasks внутри {{reference}}, закрой незавершенные пункты, при необходимости синхронизируй затронутые основные спецификации и архивируй изменение, когда оно будет соответствовать критериям завершения. Не останавливайся на отчете о статусе.", + "propose_from_discussion_prompt": "Сгенерируй изменение OpenSpec на основе недавнего обсуждения. Выдели цель, scope, ключевые требования и критерии приемки, неясные места перечисли как вопросы на уточнение и запиши реальные артефакты в openspec/changes/ (proposal, design, specs и tasks), а не останавливайся на черновой заметке.", + "propose_from_description_prompt": "Сгенерируй изменение OpenSpec на основе описания ниже. Сформируй цель, scope, ключевые требования и критерии приемки, недостающие детали перечисли как вопросы на уточнение и запиши реальные артефакты в openspec/changes/ (proposal, design, specs и tasks), а не останавливайся на черновой заметке." }, "upload": { "upload_file": "Загрузить файл", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 9934f6faa..9a6c0cd1c 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -182,7 +182,8 @@ "toast": { "finished": "完成了", "upgrade_blocked_title": "升级已阻止", - "upgrade_blocked_p2p_active": "P2P 仍在运行。请先停止,再升级 daemon。" + "upgrade_blocked_p2p_active": "P2P 仍在运行。请先停止,再升级 daemon。", + "upgrade_blocked_transport_busy": "还有 transport session 正在 turn 中。等它回到 idle 再升级 daemon。" }, "discussion": { "role_critic": "批判者", @@ -476,12 +477,12 @@ "propose_action": "Propose", "propose_from_discussion_action": "根据最近讨论生成", "propose_from_description_action": "根据下面的描述生成", - "audit_implementation_prompt": "对 {{reference}} 执行严格的实现审计,逐项对照其 OpenSpec 产物。找出所有不一致、遗漏、回归风险、边界场景缺口和缺失测试;然后直接修复代码、测试以及必要的配套改动。不要停留在报告层面,必须把实现修到与规范完全一致。", - "audit_spec_prompt": "对 {{reference}} 执行严格的规范审计。强化范围定义、需求、验收标准、边界场景、失败模式、依赖关系与可测试性,消除歧义和内部不一致,把规范修到可直接落地实施的质量。不要只给审查意见,直接修规范。", - "implement_prompt": "强力推进 {{reference}} 的实施。把工作拆成明确子任务,给子代理分配清晰所有权,整合输出,逐项补齐与规范的差距,补充或更新测试,并对最终结果负责验收。你负责调度、技术决策、集成收口和最终质量把关。", - "achieve_prompt": "按完整 OpenSpec 工作流把 {{reference}} 推到完成。检查所有剩余任务和产物,完成必要的实现与规范工作,关闭未完成项,必要时同步受影响的主规范,并在满足完成条件后归档该变更。不要只汇报状态,直接把它做完。", - "propose_from_discussion_prompt": "根据最近的讨论生成一个 OpenSpec proposal。提炼目标、范围、关键需求和验收标准;不明确的部分明确列为待确认项。先给出可直接落库的 change proposal 草稿。", - "propose_from_description_prompt": "根据下面的描述生成一个 OpenSpec proposal。整理目标、范围、关键需求和验收标准;缺失信息明确列为待确认项。先给出可直接落库的 change proposal 草稿。" + "audit_implementation_prompt": "对 {{reference}} 执行严格的实现审计,逐项对照其 OpenSpec 产物。找出所有不一致、遗漏、回归风险、边界场景缺口和缺失测试;然后直接修复代码、测试以及必要的配套改动。如果实现推进过程中需要同步变更规范产物,也要在同一次任务里直接更新 {{reference}} 下的 OpenSpec 文件。不要停留在报告层面,必须把实现修到与规范完全一致。", + "audit_spec_prompt": "对 {{reference}} 执行严格的规范审计。强化范围定义、需求、验收标准、边界场景、失败模式、依赖关系与可测试性,消除歧义和内部不一致;然后直接更新 {{reference}} 下的 proposal、design、specs、tasks,直到规范达到可直接落地实施的质量。不要只给审查意见。", + "implement_prompt": "强力推进 {{reference}} 的实施。把工作拆成明确子任务,给子代理分配清晰所有权,整合输出,逐项补齐与规范的差距,补充或更新测试,并对最终结果负责验收。实施过程中要同步维护 {{reference}} 下的 OpenSpec 产物,不要把规范更新留成后续备注。你负责调度、技术决策、集成收口和最终质量把关。", + "achieve_prompt": "按完整 OpenSpec 工作流把 {{reference}} 推到完成。检查所有剩余任务和产物,完成必要的实现与规范工作,按需直接更新 {{reference}} 下的 proposal、design、specs、tasks,关闭未完成项,必要时同步受影响的主规范,并在满足完成条件后归档该变更。不要只汇报状态,直接把它做完。", + "propose_from_discussion_prompt": "根据最近的讨论生成一个 OpenSpec 变更。提炼目标、范围、关键需求和验收标准;不明确的部分明确列为待确认项;并直接把 proposal、design、specs、tasks 写到 openspec/changes/ 下,而不是只停留在草稿说明。", + "propose_from_description_prompt": "根据下面的描述生成一个 OpenSpec 变更。整理目标、范围、关键需求和验收标准;缺失信息明确列为待确认项;并直接把 proposal、design、specs、tasks 写到 openspec/changes/ 下,而不是只停留在草稿说明。" }, "upload": { "upload_file": "上传文件", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 90a2075be..a647bbb3c 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -182,7 +182,8 @@ "toast": { "finished": "已完成", "upgrade_blocked_title": "升級已阻止", - "upgrade_blocked_p2p_active": "P2P 仍在執行中。請先停止,再升級 daemon。" + "upgrade_blocked_p2p_active": "P2P 仍在執行中。請先停止,再升級 daemon。", + "upgrade_blocked_transport_busy": "還有 transport session 正在 turn 中。等它回到 idle 再升級 daemon。" }, "discussion": { "role_critic": "評論者", @@ -476,12 +477,12 @@ "propose_action": "Propose", "propose_from_discussion_action": "根據最近討論生成", "propose_from_description_action": "根據下面的描述生成", - "audit_implementation_prompt": "對 {{reference}} 執行嚴格的實作審計,逐項對照其 OpenSpec 產物。找出所有不一致、遺漏、回歸風險、邊界情境缺口與缺失測試;然後直接修復程式碼、測試以及必要的配套改動。不要停留在報告層面,必須把實作修到與規格完全一致。", - "audit_spec_prompt": "對 {{reference}} 執行嚴格的規格審計。強化範圍定義、需求、驗收標準、邊界情境、失敗模式、依賴關係與可測試性,消除歧義與內部不一致,把規格修到可直接落地實作的品質。不要只給審查意見,直接修規格。", - "implement_prompt": "強力推進 {{reference}} 的實作。把工作拆成明確子任務,給子代理分配清楚所有權,整合輸出,逐項補齊與規格的差距,補充或更新測試,並對最終結果負責驗收。你負責調度、技術決策、整合收口與最終品質把關。", - "achieve_prompt": "依照完整 OpenSpec 工作流程把 {{reference}} 推到完成。檢查所有剩餘任務與產物,完成必要的實作與規格工作,關閉未完成項,必要時同步受影響的主規格,並在符合完成條件後封存此變更。不要只回報狀態,直接把它做完。", - "propose_from_discussion_prompt": "根據最近的討論生成一個 OpenSpec proposal。提煉目標、範圍、關鍵需求和驗收標準;不明確的部分明確列為待確認項。先給出可直接落庫的 change proposal 草稿。", - "propose_from_description_prompt": "根據下面的描述生成一個 OpenSpec proposal。整理目標、範圍、關鍵需求和驗收標準;缺失資訊明確列為待確認項。先給出可直接落庫的 change proposal 草稿。" + "audit_implementation_prompt": "對 {{reference}} 執行嚴格的實作審計,逐項對照其 OpenSpec 產物。找出所有不一致、遺漏、回歸風險、邊界情境缺口與缺失測試;然後直接修復程式碼、測試以及必要的配套改動。如果實作推進過程需要同步調整規格產物,也要在同一次任務裡直接更新 {{reference}} 下的 OpenSpec 檔案。不要停留在報告層面,必須把實作修到與規格完全一致。", + "audit_spec_prompt": "對 {{reference}} 執行嚴格的規格審計。強化範圍定義、需求、驗收標準、邊界情境、失敗模式、依賴關係與可測試性,消除歧義與內部不一致;然後直接更新 {{reference}} 下的 proposal、design、specs、tasks,直到規格達到可直接落地實作的品質。不要只給審查意見。", + "implement_prompt": "強力推進 {{reference}} 的實作。把工作拆成明確子任務,給子代理分配清楚所有權,整合輸出,逐項補齊與規格的差距,補充或更新測試,並對最終結果負責驗收。實作過程中要同步維護 {{reference}} 下的 OpenSpec 產物,不要把規格更新留成後續備註。你負責調度、技術決策、整合收口與最終品質把關。", + "achieve_prompt": "依照完整 OpenSpec 工作流程把 {{reference}} 推到完成。檢查所有剩餘任務與產物,完成必要的實作與規格工作,按需直接更新 {{reference}} 下的 proposal、design、specs、tasks,關閉未完成項,必要時同步受影響的主規格,並在符合完成條件後封存此變更。不要只回報狀態,直接把它做完。", + "propose_from_discussion_prompt": "根據最近的討論生成一個 OpenSpec 變更。提煉目標、範圍、關鍵需求和驗收標準;不明確的部分明確列為待確認項;並直接把 proposal、design、specs、tasks 寫到 openspec/changes/ 下,而不是只停留在草稿說明。", + "propose_from_description_prompt": "根據下面的描述生成一個 OpenSpec 變更。整理目標、範圍、關鍵需求和驗收標準;缺失資訊明確列為待確認項;並直接把 proposal、design、specs、tasks 寫到 openspec/changes/ 下,而不是只停留在草稿說明。" }, "upload": { "upload_file": "上傳檔案", diff --git a/web/src/server-selection.ts b/web/src/server-selection.ts index 2adeacbac..13f3c06e1 100644 --- a/web/src/server-selection.ts +++ b/web/src/server-selection.ts @@ -3,6 +3,10 @@ export interface SelectableServerInfo { name: string; } +export interface NamedSessionInfo { + name: string; +} + export function hasSelectedServer( selectedServerId: string | null, servers: readonly SelectableServerInfo[], @@ -35,7 +39,14 @@ export function shouldShowInitialConnectingGate( selectedServerId: string | null, connected: boolean, sessionsLoaded: boolean, - serversLoaded: boolean, ): boolean { - return Boolean(authReady && selectedServerId && !sessionsLoaded && !connected && !serversLoaded); + return Boolean(authReady && selectedServerId && !sessionsLoaded && !connected); +} + +export function hasResolvedActiveSession( + activeSession: string | null, + sessions: readonly NamedSessionInfo[], +): boolean { + if (!activeSession) return false; + return sessions.some((session) => session.name === activeSession); } diff --git a/web/src/session-label-api.ts b/web/src/session-label-api.ts new file mode 100644 index 000000000..86f21aa7a --- /dev/null +++ b/web/src/session-label-api.ts @@ -0,0 +1,13 @@ +import { apiFetch } from './api.js'; + +export async function updateMainSessionLabel( + serverId: string, + sessionName: string, + nextLabel: string | null, +): Promise { + await apiFetch(`/api/server/${serverId}/sessions/${encodeURIComponent(sessionName)}/label`, { + method: 'PATCH', + keepalive: true, + body: JSON.stringify({ label: nextLabel }), + }); +} diff --git a/web/src/styles.css b/web/src/styles.css index 43a11ca9a..f5559c6c7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -858,12 +858,33 @@ body { .session-ctx-input { position: absolute; left: 0; top: 0; height: 100%; background: #34d399; border-radius: 3px; } .session-ctx-cache { position: absolute; left: 0; top: 0; height: 100%; background: #818cf8; border-radius: 3px; } .session-usage-stats { display: flex; justify-content: space-between; font-size: 10px; color: #475569; } +.session-live-status-inline { display: inline-flex; align-items: center; justify-content: center; gap: 1px; min-width: 20px; color: #818cf8; min-width: 0; max-width: min(42vw, 240px); } +.session-live-status-emoji { display: inline-block; font-size: 12px; line-height: 1; filter: saturate(1.1); } +.session-live-status-emoji.robot { transform: translateY(0.2px); } +.session-live-status-text { color: #818cf8; font-size: 10px; line-height: 1.1; margin-left: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.92; } +.session-live-status-inline.running .session-live-status-emoji.gear { animation: status-gear-spin 0.8s linear infinite; transform-origin: 50% 50%; } +.session-live-status-inline.thinking .session-live-status-emoji.thought { font-size: 10px; transform: translateY(-2px) translateX(-1px); opacity: 0.94; animation: status-thought-breathe 1.8s ease-in-out infinite; transform-origin: 50% 100%; } +.session-live-status-inline.tool .session-live-status-emoji.tool { animation: status-tool-peek 1.15s ease-in-out infinite; transform-origin: 50% 50%; } +.session-live-status-inline.idle .session-live-status-emoji.sleep { font-size: 9px; transform: translateY(-3px) translateX(-1px); opacity: 0.9; animation: status-sleep-breathe 1.8s ease-in-out infinite; transform-origin: 50% 100%; } .session-usage-model { color: #a78bfa; font-size: 10px; font-weight: 500; margin-right: 6px; } .session-usage-tokens { color: #64748b; } .session-usage-badge { color: #93c5fd; border: 1px solid #1d4ed8; border-radius: 999px; padding: 1px 6px; line-height: 1.4; } .session-usage-quota-inline { color: #64748b; font-size: 9px; line-height: 1.4; white-space: nowrap; } .session-usage-cost { color: #94a3b8; } -.session-thinking-inline { color: #818cf8; font-style: italic; margin-left: auto; padding-left: 8px; } +@keyframes status-gear-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } +@keyframes status-thought-breathe { + 0%, 100% { transform: translateY(-2px) translateX(-1px) scale(0.9); opacity: 0.72; } + 50% { transform: translateY(-3px) translateX(-1px) scale(1.06); opacity: 1; } +} +@keyframes status-tool-peek { + 0%, 100% { transform: translateX(0) rotate(0deg) scale(0.98); } + 35% { transform: translateX(0.5px) rotate(-8deg) scale(1.03); } + 65% { transform: translateX(0.5px) rotate(8deg) scale(1.03); } +} +@keyframes status-sleep-breathe { + 0%, 100% { transform: translateY(-3px) translateX(-1px) scale(0.88); opacity: 0.72; } + 50% { transform: translateY(-4px) translateX(-1px) scale(1.08); opacity: 1; } +} .subsession-input-bar { display: flex; gap: 6px; padding: 6px 8px; background: #0d1117; border-top: 1px solid #1e293b; flex-shrink: 0; } .subsession-input { flex: 1; background: #1e293b; border: 1px solid #334155; border-radius: 6px; color: #e2e8f0; font-family: inherit; font-size: 13px; padding: 5px 10px; outline: none; } .subsession-input:focus { border-color: #3b82f6; } diff --git a/web/src/thinking-utils.ts b/web/src/thinking-utils.ts index a6fb5896b..1d869494f 100644 --- a/web/src/thinking-utils.ts +++ b/web/src/thinking-utils.ts @@ -66,6 +66,42 @@ export function getActiveStatusText(events: Array<{ type: string; payload?: Reco return null; } +/** + * Detect whether the current live tail is inside an active tool call. + * Only a trailing tool.call counts. A trailing tool.result means the tool already finished. + */ +export function hasActiveToolCall(events: Array<{ type: string; payload?: Record }>): boolean { + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if (e.type === 'tool.call') return true; + if (e.type === 'tool.result') return false; + if (e.type === 'session.state') { + if (e.payload?.state === 'idle') return false; + continue; + } + if (e.type === 'assistant.thinking' || THINKING_SKIP_TYPES.has(e.type)) continue; + return false; + } + return false; +} + +/** + * Read the most recent authoritative session.state from the timeline tail. + * This is more reliable than outer session store state for footer rendering, + * because timeline updates can arrive before higher-level session snapshots settle. + */ +export function getTailSessionState( + events: Array<{ type: string; payload?: Record }>, +): string | null { + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if (e.type !== 'session.state') continue; + const state = e.payload?.state; + return typeof state === 'string' && state ? state : null; + } + return null; +} + export function isRunningSessionState(sessionState: string | undefined): boolean { return sessionState === 'running'; } diff --git a/web/src/timeline-running.ts b/web/src/timeline-running.ts index 90a710feb..2f3f7043f 100644 --- a/web/src/timeline-running.ts +++ b/web/src/timeline-running.ts @@ -1,6 +1,7 @@ import type { TimelineEvent } from '../../src/shared/timeline/types.js'; const RUNNING_TIMELINE_EVENT_TYPES = new Set([ + 'assistant.thinking', 'assistant.text', 'tool.call', 'tool.result', diff --git a/web/src/transport-queue.ts b/web/src/transport-queue.ts index 3e7b33505..542da142d 100644 --- a/web/src/transport-queue.ts +++ b/web/src/transport-queue.ts @@ -4,3 +4,15 @@ export function extractTransportPendingMessages(value: unknown): string[] { .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) .filter((entry) => entry.length > 0); } + +export function mergeTransportPendingMessagesForRunningState( + existing: string[] | null | undefined, + pendingFromEvent: unknown, + hasPendingMessagesField: boolean, +): string[] { + const existingMessages = Array.isArray(existing) ? existing.filter((entry) => typeof entry === 'string' && entry.length > 0) : []; + if (!hasPendingMessagesField) return existingMessages; + const nextMessages = extractTransportPendingMessages(pendingFromEvent); + if (nextMessages.length > 0) return nextMessages; + return existingMessages; +} diff --git a/web/src/ws-client.ts b/web/src/ws-client.ts index 2eb616428..5ed98b36f 100644 --- a/web/src/ws-client.ts +++ b/web/src/ws-client.ts @@ -30,6 +30,7 @@ export type ServerMessage = | { type: typeof DAEMON_MSG.RECONNECTED } | { type: typeof DAEMON_MSG.DISCONNECTED } | { type: typeof DAEMON_MSG.UPGRADE_BLOCKED; reason: 'p2p_active'; activeRunIds?: string[] } + | { type: typeof DAEMON_MSG.UPGRADE_BLOCKED; reason: 'transport_busy'; activeSessionNames?: string[] } | { type: 'daemon.error'; kind: 'uncaughtException' | 'unhandledRejection' | 'warning'; message: string; stack?: string; ts: number } | { type: 'session_list'; daemonVersion?: string | null; sessions: Array<{ name: string; project: string; role: string; agentType: string; agentVersion?: string; state: string; projectDir?: string; runtimeType?: 'process' | 'transport'; label?: string; description?: string; qwenModel?: string; requestedModel?: string; activeModel?: string; qwenAuthType?: string; qwenAuthLimit?: string; qwenAvailableModels?: string[]; modelDisplay?: string; planLabel?: string; permissionLabel?: string; quotaLabel?: string; quotaUsageLabel?: string; quotaMeta?: import('../../shared/provider-quota.js').ProviderQuotaMeta | null; effort?: import('../../shared/effort-levels.js').TransportEffortLevel }> } | { type: 'outbound'; platform: string; channelId: string; content: string } diff --git a/web/test/chat-view-tool-format.test.tsx b/web/test/chat-view-tool-format.test.tsx index 7c068f82f..8e8e17344 100644 --- a/web/test/chat-view-tool-format.test.tsx +++ b/web/test/chat-view-tool-format.test.tsx @@ -3,7 +3,7 @@ */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { h } from 'preact'; -import { render, screen, cleanup, fireEvent } from '@testing-library/preact'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/preact'; if (!HTMLElement.prototype.scrollIntoView) { HTMLElement.prototype.scrollIntoView = vi.fn(); @@ -27,8 +27,13 @@ vi.mock('../src/components/FileBrowser.js', () => ({ FileBrowser: () => null, })); +vi.mock('../src/api.js', () => ({ + downloadAttachment: vi.fn().mockResolvedValue(undefined), +})); + import { ChatView } from '../src/components/ChatView.js'; import type { TimelineEvent } from '../src/ws-client.js'; +import { downloadAttachment } from '../src/api.js'; function makeEvent(overrides: Partial & { type: string; payload: Record }): TimelineEvent { return { @@ -146,6 +151,65 @@ describe('ChatView tool payload formatting', () => { expect(screen.getByText('output')).toBeDefined(); }); + it('connects Windows file paths in tool output to preview and download', async () => { + const fsReadFile = vi.fn(() => 'req-win-path'); + const onMessage = vi.fn(() => vi.fn()); + const events = [ + makeEvent({ + type: 'tool.result', + payload: { output: { path: 'C:\\Users\\admin\\screenshot.png' } }, + }), + ]; + + const { container } = render( + , + ); + + const link = container.querySelector('.chat-path-link') as HTMLElement | null; + const button = container.querySelector('.chat-dl-btn') as HTMLButtonElement | null; + expect(link?.textContent).toBe('C:\\Users\\admin\\screenshot.png'); + expect(button).not.toBeNull(); + + fireEvent.click(button!); + + expect(fsReadFile).toHaveBeenCalledWith('C:\\Users\\admin\\screenshot.png'); + onMessage.mock.calls[0][0]({ + type: 'fs.read_response', + requestId: 'req-win-path', + downloadId: 'dl-win-path', + }); + await waitFor(() => { + expect(downloadAttachment).toHaveBeenCalledWith('server-1', 'dl-win-path'); + }); + }); + + it('keeps adjacent Chinese-punctuated URLs as external links instead of file paths', () => { + const events = [ + makeEvent({ + type: 'assistant.text', + payload: { + text: 'https://blog.csdn.net/2502_91125447/article/details/146912737(CSDN博客 - PCDN市场深水区)https://m.c114.com.cn/w16-1296322.html⬇(C114 - PCDN即将成为历史)', + streaming: false, + }, + }), + ]; + + const { container } = render(); + + const externalLinks = Array.from(container.querySelectorAll('.chat-external-link')) as HTMLAnchorElement[]; + expect(externalLinks.map((el) => el.textContent)).toEqual([ + 'https://blog.csdn.net/2502_91125447/article/details/146912737', + 'https://m.c114.com.cn/w16-1296322.html', + ]); + expect(container.querySelector('.chat-path-link')).toBeNull(); + expect(container.querySelector('.chat-dl-btn')).toBeNull(); + }); + it('renders OpenClaw transport tool rows for realistic sessions_send payloads', () => { const events = [ makeEvent({ diff --git a/web/test/components/ChatView.test.tsx b/web/test/components/ChatView.test.tsx index c89806838..93e525a37 100644 --- a/web/test/components/ChatView.test.tsx +++ b/web/test/components/ChatView.test.tsx @@ -206,6 +206,58 @@ describe('ChatView', () => { expect(chatMarkdownRenderSpy.mock.calls.filter(([text]) => text === 'stable block')).toHaveLength(1); }); + it('does not render running or idle session states as chat rows', () => { + const { container } = render( + , + ); + + expect(container.textContent).not.toContain('Agent working...'); + expect(container.textContent).not.toContain('Agent idle'); + expect(container.textContent).toContain('real message'); + }); + + it('still renders non-live session state entries such as stopped', () => { + const { container } = render( + , + ); + + expect(container.textContent).toContain('Session stopped'); + }); + it('restores mobile keyboard scroll position from bottom offset instead of snapping to top', async () => { const initialEvents = [ { diff --git a/web/test/components/FileBrowser.test.tsx b/web/test/components/FileBrowser.test.tsx index f677514ed..8050954f7 100644 --- a/web/test/components/FileBrowser.test.tsx +++ b/web/test/components/FileBrowser.test.tsx @@ -962,6 +962,59 @@ describe('FileBrowser', () => { expect((ws.fsGitDiff as any).mock.calls).toHaveLength(0); }); + it('preserves a manual diff tab selection when the same preview path refreshes', async () => { + const { ws } = makeWsFactory(); + const view = render( + diff before
', + }} + onConfirm={vi.fn()} + />, + ); + + const toggle = screen.getByTitle('Toggle diff view'); + expect(document.querySelector('.fb-diff')).toBeNull(); + expect(toggle.className).not.toContain('active'); + + await act(async () => { + fireEvent.click(toggle); + }); + + expect(document.querySelector('.fb-diff')).not.toBeNull(); + expect(toggle.className).toContain('active'); + + view.rerender( + diff after', + }} + onConfirm={vi.fn()} + />, + ); + + expect(document.querySelector('.fb-diff')?.textContent).toContain('diff after'); + expect(screen.getByTitle('Toggle diff view').className).toContain('active'); + }); + it('fetches preview data when a floating preview is hydrated with a loading state', () => { const { ws } = makeWsFactory(); render( diff --git a/web/test/components/SessionControls.test.tsx b/web/test/components/SessionControls.test.tsx index 9abbf4038..edc89a152 100644 --- a/web/test/components/SessionControls.test.tsx +++ b/web/test/components/SessionControls.test.tsx @@ -27,22 +27,22 @@ vi.mock('react-i18next', () => ({ if (key === 'openspec.propose_from_discussion_action') return 'propose_from_discussion_action'; if (key === 'openspec.propose_from_description_action') return 'propose_from_description_action'; if (key === 'openspec.audit_implementation_prompt') { - return `audit implementation ${(opts?.reference as string) ?? ''}, fix code gaps`; + return `audit implementation ${(opts?.reference as string) ?? ''}, fix code gaps and update openspec files`; } if (key === 'openspec.audit_spec_prompt') { - return `audit spec ${(opts?.reference as string) ?? ''}, fix spec gaps`; + return `audit spec ${(opts?.reference as string) ?? ''}, update proposal design specs and tasks`; } if (key === 'openspec.implement_prompt') { - return `delegate ${(opts?.reference as string) ?? ''}, split tasks and accept`; + return `implement ${(opts?.reference as string) ?? ''}, keep openspec artifacts aligned while coding`; } if (key === 'openspec.achieve_prompt') { - return `complete ${(opts?.reference as string) ?? ''}, finish remaining work and archive if done`; + return `complete ${(opts?.reference as string) ?? ''}, update proposal design specs tasks and archive if done`; } if (key === 'openspec.propose_from_discussion_prompt') { - return 'generate openspec proposal from recent discussion'; + return 'generate openspec change from recent discussion and write proposal design specs tasks'; } if (key === 'openspec.propose_from_description_prompt') { - return 'generate openspec proposal from description below'; + return 'generate openspec change from description below and write proposal design specs tasks'; } if (key === 'session.transport_send_queued_collapsed') { return `${opts?.count ?? 0} queued · showing latest only`; @@ -175,6 +175,20 @@ const makeWs = () => { }; }; +function expectSendPayload(ws: ReturnType, payload: Record): void { + expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', expect.objectContaining({ + ...payload, + commandId: expect.any(String), + })); +} + +function expectLastSendPayload(ws: ReturnType, payload: Record): void { + expect(ws.sendSessionCommand).toHaveBeenLastCalledWith('send', expect.objectContaining({ + ...payload, + commandId: expect.any(String), + })); +} + const makeQuickData = () => ({ data: { history: [], sessionHistory: {}, commands: [], phrases: [] }, loaded: true, @@ -317,7 +331,7 @@ afterEach(() => { input.textContent = 'run tests'; fireEvent.input(input); fireEvent.keyDown(input, { key: 'Enter' }); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'my-session', text: 'run tests', }); @@ -408,12 +422,33 @@ afterEach(() => { fireEvent.input(input); fireEvent.click(screen.getByRole('button', { name: /send/i })); expect(ws.sendSessionCommand).toHaveBeenCalledOnce(); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'my-session', text: 'run tests', }); }); + it('generates a distinct commandId for each send', () => { + const ws = makeWs(); + render(); + const input = screen.getByRole('textbox') as HTMLDivElement; + + input.textContent = 'first'; + fireEvent.input(input); + fireEvent.click(screen.getByRole('button', { name: /send/i })); + + input.textContent = 'second'; + fireEvent.input(input); + fireEvent.click(screen.getByRole('button', { name: /send/i })); + + expect(ws.sendSessionCommand).toHaveBeenCalledTimes(2); + const firstPayload = ws.sendSessionCommand.mock.calls[0]?.[1] as { commandId?: string }; + const secondPayload = ws.sendSessionCommand.mock.calls[1]?.[1] as { commandId?: string }; + expect(firstPayload.commandId).toEqual(expect.any(String)); + expect(secondPayload.commandId).toEqual(expect.any(String)); + expect(firstPayload.commandId).not.toBe(secondPayload.commandId); + }); + it('sends advanced p2p config fields when config mode is used', async () => { const ws = makeWs(); getUserPrefMock.mockImplementation(async (key: unknown) => { @@ -455,7 +490,7 @@ afterEach(() => { fireEvent.input(input); fireEvent.click(screen.getByRole('button', { name: /^send$/i })); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'my-session', text: 'ship it', p2pAtTargets: [ @@ -608,7 +643,7 @@ afterEach(() => { fireEvent.click(within(dialog).getByRole('checkbox')); fireEvent.click(within(dialog).getByRole('button', { name: /^send$/i })); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'my-session', text: 'first combo', p2pAtTargets: [ @@ -629,7 +664,7 @@ afterEach(() => { fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); expect(screen.queryByText('combo_send_confirm_title')).toBeNull(); - expect(ws.sendSessionCommand).toHaveBeenLastCalledWith('send', { + expectLastSendPayload(ws, { sessionName: 'my-session', text: 'second combo', p2pAtTargets: [ @@ -659,7 +694,7 @@ afterEach(() => { const dialog = screen.getByText('combo_send_confirm_title').closest('.dialog') as HTMLElement; fireEvent.click(within(dialog).getByRole('button', { name: /^send$/i })); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'my-session', text: 'direct combo', p2pAtTargets: [ @@ -744,7 +779,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'audit_action' })); fireEvent.click(screen.getByRole('button', { name: 'audit_implementation_action' })); - expect(screen.getByRole('textbox').textContent).toBe('audit implementation @openspec/changes/change-a, fix code gaps'); + expect(screen.getByRole('textbox').textContent).toBe('audit implementation @openspec/changes/change-a, fix code gaps and update openspec files'); expect(ws.sendSessionCommand).not.toHaveBeenCalled(); }); @@ -773,7 +808,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'audit_action' })); fireEvent.click(screen.getByRole('button', { name: 'audit_spec_action' })); - expect(screen.getByRole('textbox').textContent).toBe('audit spec @openspec/changes/change-a, fix spec gaps'); + expect(screen.getByRole('textbox').textContent).toBe('audit spec @openspec/changes/change-a, update proposal design specs and tasks'); expect(ws.sendSessionCommand).not.toHaveBeenCalled(); }); @@ -801,7 +836,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'implement_action' })); - expect(screen.getByRole('textbox').textContent).toBe('delegate @openspec/changes/change-a, split tasks and accept'); + expect(screen.getByRole('textbox').textContent).toBe('implement @openspec/changes/change-a, keep openspec artifacts aligned while coding'); expect(ws.sendSessionCommand).not.toHaveBeenCalled(); }); @@ -829,9 +864,9 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'achieve_action' })); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'my-session', - text: 'complete @openspec/changes/change-a, finish remaining work and archive if done', + text: 'complete @openspec/changes/change-a, update proposal design specs tasks and archive if done', }); }); @@ -849,7 +884,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'propose_action' })); fireEvent.click(screen.getByRole('button', { name: 'propose_from_discussion_action' })); - expect(screen.getByRole('textbox').textContent).toBe('generate openspec proposal from recent discussion'); + expect(screen.getByRole('textbox').textContent).toBe('generate openspec change from recent discussion and write proposal design specs tasks'); expect(ws.sendSessionCommand).not.toHaveBeenCalled(); }); @@ -867,7 +902,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'propose_action' })); fireEvent.click(screen.getByRole('button', { name: 'propose_from_description_action' })); - expect(screen.getByRole('textbox').textContent).toBe('generate openspec proposal from description below'); + expect(screen.getByRole('textbox').textContent).toBe('generate openspec change from description below and write proposal design specs tasks'); expect(ws.sendSessionCommand).not.toHaveBeenCalled(); }); @@ -1146,7 +1181,7 @@ afterEach(() => { input.textContent = 'enter message'; fireEvent.input(input); fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'my-session', text: 'enter message', }); @@ -1184,7 +1219,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: /^medium$/i })); fireEvent.click(screen.getByRole('button', { name: /high/i })); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'qwen-session', text: '/thinking high', }); @@ -1278,7 +1313,7 @@ afterEach(() => { fireEvent.keyDown(input, { key: 'Escape' }); // Transport sessions send /stop instead of raw escape byte - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { sessionName: 'qwen-session', text: '/stop' }); + expectSendPayload(ws, { sessionName: 'qwen-session', text: '/stop' }); expect(ws.sendInput).not.toHaveBeenCalled(); }); @@ -1300,7 +1335,7 @@ afterEach(() => { const stopBtn = screen.getByRole('button', { name: /^stop$/i }) as HTMLButtonElement; expect(stopBtn.disabled).toBe(false); fireEvent.click(stopBtn); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'codex-sdk-session', text: '/stop', }); @@ -1708,7 +1743,7 @@ afterEach(() => { fireEvent.input(input); // Send fireEvent.click(screen.getByRole('button', { name: /send/i })); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'my-session', text: '@/tmp/data.csv analyze this', }); @@ -1897,7 +1932,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: /^medium$/i })); fireEvent.click(screen.getByRole('button', { name: /high/i })); - expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + expectSendPayload(ws, { sessionName: 'codex-sdk-session', text: '/thinking high', }); diff --git a/web/test/components/SessionPane.test.tsx b/web/test/components/SessionPane.test.tsx index 4fc657058..00a94e18d 100644 --- a/web/test/components/SessionPane.test.tsx +++ b/web/test/components/SessionPane.test.tsx @@ -6,6 +6,7 @@ import { render, screen, cleanup, fireEvent } from '@testing-library/preact'; import { h } from 'preact'; const addOptimisticUserMessageMock = vi.fn(); +let timelineEventsMock: any[] = []; vi.mock('../../src/components/TerminalView.js', () => ({ TerminalView: () => null })); vi.mock('../../src/components/ChatView.js', () => ({ ChatView: () => null })); @@ -18,7 +19,7 @@ vi.mock('../../src/components/SessionControls.js', () => ({ })); vi.mock('../../src/hooks/useTimeline.js', () => ({ useTimeline: () => ({ - events: [], + events: timelineEventsMock, loading: false, refreshing: false, loadingOlder: false, @@ -30,11 +31,18 @@ vi.mock('../../src/hooks/useTimeline.js', () => ({ vi.mock('../../src/thinking-utils.js', () => ({ getActiveThinkingTs: () => null, getActiveStatusText: () => null, + hasActiveToolCall: () => false, + getTailSessionState: (events: Array<{ type: string; payload?: Record }>) => { + for (let i = events.length - 1; i >= 0; i--) { + if (events[i].type === 'session.state') return String(events[i].payload?.state ?? ''); + } + return null; + }, })); vi.mock('../../src/cost-tracker.js', () => ({ recordCost: vi.fn() })); vi.mock('../../src/format-label.js', () => ({ formatLabel: (x: string) => x })); vi.mock('../../src/components/UsageFooter.js', () => ({ - UsageFooter: (props: any) =>
{props.quotaLabel ?? props.planLabel ?? 'footer'}
, + UsageFooter: (props: any) =>
{props.quotaLabel ?? props.planLabel ?? 'footer'}
, })); import { SessionPane } from '../../src/components/SessionPane.js'; @@ -42,6 +50,7 @@ import { SessionPane } from '../../src/components/SessionPane.js'; describe('SessionPane', () => { beforeEach(() => { addOptimisticUserMessageMock.mockReset(); + timelineEventsMock = []; }); afterEach(() => { @@ -130,4 +139,35 @@ describe('SessionPane', () => { fireEvent.click(screen.getByRole('button', { name: 'send' })); expect(addOptimisticUserMessageMock).toHaveBeenCalledWith('queued text'); }); + + it('prefers timeline tail running state over stale outer idle state for footer status', () => { + timelineEventsMock = [ + { type: 'session.state', payload: { state: 'running' } }, + { type: 'tool.result', payload: { ok: true } }, + ]; + + render( + , + ); + + expect(screen.getByTestId('usage-footer').getAttribute('data-state')).toBe('running'); + }); }); diff --git a/web/test/components/SessionTabs.test.tsx b/web/test/components/SessionTabs.test.tsx index cf8a19ee1..80c2e2a98 100644 --- a/web/test/components/SessionTabs.test.tsx +++ b/web/test/components/SessionTabs.test.tsx @@ -11,6 +11,14 @@ vi.mock('react-i18next', () => ({ }), })); +const getUserPrefMock = vi.fn().mockResolvedValue(null); +const saveUserPrefMock = vi.fn().mockResolvedValue(undefined); + +vi.mock('../../src/api.js', () => ({ + getUserPref: (...args: unknown[]) => getUserPrefMock(...args), + saveUserPref: (...args: unknown[]) => saveUserPrefMock(...args), +})); + import { SessionTabs } from '../../src/components/SessionTabs.js'; import type { SessionInfo } from '../../src/types.js'; @@ -33,6 +41,8 @@ const defaultProps = { describe('SessionTabs', () => { beforeEach(() => { + getUserPrefMock.mockResolvedValue(null); + saveUserPrefMock.mockResolvedValue(undefined); // Ensure localStorage is available (jsdom may provide a broken stub) if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.setItem !== 'function') { const store: Record = {}; @@ -164,4 +174,68 @@ describe('SessionTabs', () => { expect(onStopProject).toHaveBeenCalledOnce(); expect(onStopProject).toHaveBeenCalledWith('proj-1'); }); + + it('uses the current label as the rename input value and commits a label update', () => { + const onRenameSession = vi.fn(); + const sessions: SessionInfo[] = [{ + name: 'deck_proj_brain', + project: 'my-project', + role: 'brain', + agentType: 'brain', + state: 'idle', + label: 'Main Label', + }]; + + render( + , + ); + + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input.value).toBe('Main Label'); + + fireEvent.input(input, { target: { value: 'Readable Main' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onRenameSession).toHaveBeenCalledWith('deck_proj_brain', 'Readable Main'); + }); + + it('allows clearing the label so the session falls back to the project name', () => { + const onRenameSession = vi.fn(); + const sessions: SessionInfo[] = [{ + name: 'deck_proj_brain', + project: 'my-project', + role: 'brain', + agentType: 'brain', + state: 'idle', + label: 'Main Label', + }]; + + render( + , + ); + + const input = screen.getByRole('textbox') as HTMLInputElement; + fireEvent.input(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(onRenameSession).toHaveBeenCalledWith('deck_proj_brain', null); + }); }); diff --git a/web/test/components/SubSessionWindow.test.tsx b/web/test/components/SubSessionWindow.test.tsx index e81268b78..bfd408b38 100644 --- a/web/test/components/SubSessionWindow.test.tsx +++ b/web/test/components/SubSessionWindow.test.tsx @@ -20,7 +20,8 @@ vi.mock('../../src/components/ChatView.js', () => ({ })); const sessionControlsSpy = vi.fn((props: any) =>
); -const usageFooterSpy = vi.fn((props: any) =>
); +const usageFooterSpy = vi.fn((props: any) =>
); +let timelineEventsMock: any[] = []; vi.mock('../../src/components/SessionControls.js', () => ({ SessionControls: (props: any) => sessionControlsSpy(props), @@ -32,7 +33,7 @@ vi.mock('../../src/components/UsageFooter.js', () => ({ vi.mock('../../src/hooks/useTimeline.js', () => ({ useTimeline: () => ({ - events: [], + events: timelineEventsMock, refreshing: false, }), })); @@ -90,6 +91,7 @@ describe('SubSessionWindow metadata wiring', () => { beforeEach(() => { cleanup(); vi.clearAllMocks(); + timelineEventsMock = []; }); it('passes model, level, and quota metadata through for transport sub-sessions', async () => { @@ -159,6 +161,41 @@ describe('SubSessionWindow metadata wiring', () => { expect(controls?.dataset.queued).toBe('queued one|queued two'); }); }); + + it('prefers timeline tail running state over stale outer idle state for footer status', async () => { + timelineEventsMock = [ + { type: 'session.state', payload: { state: 'running' } }, + { type: 'tool.result', payload: { ok: true } }, + ]; + + const sub = makeSubSession({ + type: 'codex-sdk', + runtimeType: 'transport' as any, + state: 'idle', + } as any); + + render( + , + ); + + await waitFor(() => { + const footer = document.querySelector('[data-testid="usage-footer"]') as HTMLElement | null; + expect(footer?.dataset.state).toBe('running'); + }); + }); }); describe('SubSessionWindow terminal subscription raw mode', () => { diff --git a/web/test/server-selection.test.ts b/web/test/server-selection.test.ts index d20f54cd8..81a35c7bf 100644 --- a/web/test/server-selection.test.ts +++ b/web/test/server-selection.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getSelectedServerName, + hasResolvedActiveSession, hasSelectedServer, shouldResetSelectedServer, shouldShowInitialConnectingGate, @@ -60,14 +61,27 @@ describe('shouldResetSelectedServer', () => { }); describe('shouldShowInitialConnectingGate', () => { - it('shows the gate only while the server list is still loading', () => { - expect(shouldShowInitialConnectingGate(true, 'srv-1', false, false, false)).toBe(true); - expect(shouldShowInitialConnectingGate(true, 'srv-1', false, false, true)).toBe(false); + it('keeps the gate visible until websocket or session data resolves', () => { + expect(shouldShowInitialConnectingGate(true, 'srv-1', false, false)).toBe(true); + expect(shouldShowInitialConnectingGate(true, 'srv-1', true, false)).toBe(false); + expect(shouldShowInitialConnectingGate(true, 'srv-1', false, true)).toBe(false); }); it('does not show the gate without a selected server or after a connection is established', () => { - expect(shouldShowInitialConnectingGate(true, null, false, false, false)).toBe(false); - expect(shouldShowInitialConnectingGate(true, 'srv-1', true, false, false)).toBe(false); - expect(shouldShowInitialConnectingGate(true, 'srv-1', false, true, false)).toBe(false); + expect(shouldShowInitialConnectingGate(true, null, false, false)).toBe(false); + expect(shouldShowInitialConnectingGate(false, 'srv-1', false, false)).toBe(false); + }); +}); + +describe('hasResolvedActiveSession', () => { + it('returns false for a stale active session restored before the session list arrives', () => { + expect(hasResolvedActiveSession('deck_proj_brain', [])).toBe(false); + }); + + it('returns true once the active session exists in the current session list', () => { + expect(hasResolvedActiveSession('deck_proj_brain', [ + { name: 'deck_proj_brain' }, + { name: 'deck_proj_w1' }, + ])).toBe(true); }); }); diff --git a/web/test/session-label-api.test.ts b/web/test/session-label-api.test.ts new file mode 100644 index 000000000..1964fb7a9 --- /dev/null +++ b/web/test/session-label-api.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiFetchMock = vi.fn(); + +vi.mock('../src/api.js', () => ({ + apiFetch: (...args: unknown[]) => apiFetchMock(...args), +})); + +describe('updateMainSessionLabel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('persists a label update via the main-session label route with keepalive enabled', async () => { + const { updateMainSessionLabel } = await import('../src/session-label-api.js'); + + await updateMainSessionLabel('srv-1', 'deck_proj_brain', 'Readable Main'); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/server/srv-1/sessions/deck_proj_brain/label', + { + method: 'PATCH', + keepalive: true, + body: JSON.stringify({ label: 'Readable Main' }), + }, + ); + }); + + it('persists label clearing as null', async () => { + const { updateMainSessionLabel } = await import('../src/session-label-api.js'); + + await updateMainSessionLabel('srv-1', 'deck_proj_brain', null); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/server/srv-1/sessions/deck_proj_brain/label', + { + method: 'PATCH', + keepalive: true, + body: JSON.stringify({ label: null }), + }, + ); + }); +}); diff --git a/web/test/thinking-utils.test.ts b/web/test/thinking-utils.test.ts index fd61cb4cb..0de8684db 100644 --- a/web/test/thinking-utils.test.ts +++ b/web/test/thinking-utils.test.ts @@ -1,19 +1,48 @@ import { describe, expect, it } from 'vitest'; -import { getActiveStatusText } from '../src/thinking-utils.js'; +import { getTailSessionState, hasActiveToolCall } from '../src/thinking-utils.js'; -describe('getActiveStatusText', () => { - it('returns the latest trailing status label', () => { - expect(getActiveStatusText([ - { type: 'assistant.text', payload: { text: 'done' } }, - { type: 'agent.status', payload: { status: 'compacting', label: 'Compacting conversation...' } }, - ])).toBe('Compacting conversation...'); +describe('hasActiveToolCall', () => { + it('does not treat trailing agent.status during thinking as a tool call', () => { + expect(hasActiveToolCall([ + { type: 'assistant.thinking', ts: 1 }, + { type: 'agent.status', payload: { label: 'thinking 4s' } }, + { type: 'session.state', payload: { state: 'running' } }, + ] as any)).toBe(false); }); - it('treats an unlabeled trailing status as an explicit clear', () => { - expect(getActiveStatusText([ - { type: 'agent.status', payload: { status: 'compacting', label: 'Compacting conversation...' } }, - { type: 'agent.status', payload: { status: null, label: null } }, - ])).toBeNull(); + it('treats a trailing tool.call as active', () => { + expect(hasActiveToolCall([ + { type: 'assistant.thinking', ts: 1 }, + { type: 'tool.call', payload: { tool: 'Read' } }, + { type: 'agent.status', payload: { label: 'Reading file...' } }, + { type: 'session.state', payload: { state: 'running' } }, + ] as any)).toBe(true); + }); + + it('does not treat a completed tool.result as an active tool call', () => { + expect(hasActiveToolCall([ + { type: 'tool.call', payload: { tool: 'Read' } }, + { type: 'tool.result', payload: { ok: true } }, + { type: 'agent.status', payload: { label: 'thinking 1s' } }, + { type: 'session.state', payload: { state: 'running' } }, + ] as any)).toBe(false); + }); +}); + +describe('getTailSessionState', () => { + it('returns the latest authoritative session state from the timeline tail', () => { + expect(getTailSessionState([ + { type: 'session.state', payload: { state: 'idle' } }, + { type: 'tool.result', payload: { ok: true } }, + { type: 'session.state', payload: { state: 'running' } }, + ] as any)).toBe('running'); + }); + + it('returns null when no session.state event exists', () => { + expect(getTailSessionState([ + { type: 'assistant.thinking', ts: 1 }, + { type: 'tool.call', payload: { tool: 'Read' } }, + ] as any)).toBe(null); }); }); diff --git a/web/test/timeline-running.test.ts b/web/test/timeline-running.test.ts index decd21386..9924c1a55 100644 --- a/web/test/timeline-running.test.ts +++ b/web/test/timeline-running.test.ts @@ -1,26 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { isIdleSessionStateTimelineEvent, isRunningTimelineEvent } from '../src/timeline-running.js'; -describe('timeline session activity helpers', () => { - it('treats assistant and tool events as running signals', () => { - expect(isRunningTimelineEvent({ type: 'assistant.text' } as any)).toBe(true); - expect(isRunningTimelineEvent({ type: 'tool.call' } as any)).toBe(true); - expect(isRunningTimelineEvent({ type: 'tool.result' } as any)).toBe(true); - expect(isRunningTimelineEvent({ type: 'assistant.thinking' } as any)).toBe(false); +import { isRunningTimelineEvent } from '../src/timeline-running.js'; + +describe('isRunningTimelineEvent', () => { + it('treats assistant.thinking as a running signal', () => { + expect(isRunningTimelineEvent({ type: 'assistant.thinking' } as any)).toBe(true); }); - it('treats realtime session.state idle as an idle flash signal', () => { - expect(isIdleSessionStateTimelineEvent({ - type: 'session.state', - payload: { state: 'idle' }, - } as any)).toBe(true); - expect(isIdleSessionStateTimelineEvent({ - type: 'session.state', - payload: { state: 'running' }, - } as any)).toBe(false); - expect(isIdleSessionStateTimelineEvent({ - type: 'assistant.text', - payload: { text: 'done' }, - } as any)).toBe(false); + it('keeps tool.call and assistant.text as running signals', () => { + expect(isRunningTimelineEvent({ type: 'tool.call' } as any)).toBe(true); + expect(isRunningTimelineEvent({ type: 'assistant.text' } as any)).toBe(true); }); }); diff --git a/web/test/transport-queue.test.ts b/web/test/transport-queue.test.ts new file mode 100644 index 000000000..7bb5d6630 --- /dev/null +++ b/web/test/transport-queue.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractTransportPendingMessages, + mergeTransportPendingMessagesForRunningState, +} from '../src/transport-queue.js'; + +describe('extractTransportPendingMessages', () => { + it('keeps only non-empty string entries', () => { + expect(extractTransportPendingMessages([' one ', '', 1, null, 'two'])).toEqual(['one', 'two']); + }); +}); + +describe('mergeTransportPendingMessagesForRunningState', () => { + it('preserves the existing queue when running reports no pendingMessages field', () => { + expect(mergeTransportPendingMessagesForRunningState(['queued one'], undefined, false)).toEqual(['queued one']); + }); + + it('preserves the existing queue when running reports an empty pendingMessages array', () => { + expect(mergeTransportPendingMessagesForRunningState(['queued one', 'queued two'], [], true)).toEqual(['queued one', 'queued two']); + }); + + it('replaces the queue when running reports a non-empty pendingMessages array', () => { + expect(mergeTransportPendingMessagesForRunningState(['queued one'], ['queued two'], true)).toEqual(['queued two']); + }); + + it('returns an empty queue when nothing is queued yet', () => { + expect(mergeTransportPendingMessagesForRunningState([], [], true)).toEqual([]); + }); +}); diff --git a/web/test/usage-footer.test.tsx b/web/test/usage-footer.test.tsx index 615d1e3b5..454bb0ead 100644 --- a/web/test/usage-footer.test.tsx +++ b/web/test/usage-footer.test.tsx @@ -34,7 +34,7 @@ afterEach(() => { }); describe('UsageFooter', () => { - it('only shows the animated thinking dots while the session is running', () => { + it('prioritizes active thinking over stale idle state and renders running states inline', () => { const { container, rerender } = render( { />, ); - expect(container.querySelector('.chat-thinking-dots')).toBeNull(); + const staleIdleStatus = container.querySelector('.session-live-status-inline') as HTMLSpanElement | null; + expect(staleIdleStatus?.textContent).toContain('🤖'); + expect(staleIdleStatus?.textContent).toContain('💭'); + expect(staleIdleStatus?.getAttribute('aria-label')).toContain('thinking'); + expect(container.querySelector('.session-live-status-inline.thinking .session-live-status-emoji.thought')).toBeTruthy(); + expect(container.querySelector('.session-live-status-inline.idle')).toBeNull(); rerender( { />, ); - expect(container.querySelector('.chat-thinking-dots')).toBeTruthy(); + const runningStatus = container.querySelector('.session-live-status-inline') as HTMLSpanElement | null; + expect(runningStatus?.textContent).toContain('🤖'); + expect(runningStatus?.textContent).toContain('💭'); + expect(runningStatus?.getAttribute('aria-label')).toContain('thinking'); + expect(container.querySelector('.session-live-status-inline.thinking')).toBeTruthy(); + expect(container.querySelector('.session-live-status-inline.thinking .session-live-status-emoji.thought')).toBeTruthy(); + expect(container.querySelector('.session-live-status-text')?.textContent).toContain('thinking'); + + rerender( + , + ); + + const plainRunningStatus = container.querySelector('.session-live-status-inline') as HTMLSpanElement | null; + expect(plainRunningStatus?.textContent).toContain('🤖'); + expect(plainRunningStatus?.textContent).toContain('⚙️'); + expect(container.querySelector('.session-live-status-inline.running .session-live-status-emoji.gear')).toBeTruthy(); + }); + + it('shows tool-call icon when explicit running status text is present', () => { + const { container } = render( + , + ); + + expect((container.querySelector('.session-live-status-inline') as HTMLSpanElement | null)?.textContent).toContain('🔍'); + expect(container.querySelector('.session-live-status-inline.tool .session-live-status-emoji.tool')).toBeTruthy(); + expect(container.querySelector('.session-live-status-text')?.textContent).toBe('Reading file...'); + expect((container.querySelector('.session-live-status-inline') as HTMLSpanElement | null)?.getAttribute('aria-label')).toBe('Reading file...'); }); it('renders explicit quota label inline in the ctx footer', () => { diff --git a/web/test/use-sub-sessions-metadata.test.tsx b/web/test/use-sub-sessions-metadata.test.tsx index 5d3a1ca69..8d2f17edb 100644 --- a/web/test/use-sub-sessions-metadata.test.tsx +++ b/web/test/use-sub-sessions-metadata.test.tsx @@ -217,7 +217,7 @@ describe('sub-session metadata via subsession.sync', () => { expect(captured[0].quotaUsageLabel).toBe('today 20/1000'); }); - it('preserves queued transport messages until running explicitly reports an empty queue', async () => { + it('preserves queued transport messages while the drained send is still running and clears on idle', async () => { const { ws, send } = createMockWs(); render(); await waitFor(() => expect(ws.onMessage).toHaveBeenCalled()); @@ -261,6 +261,17 @@ describe('sub-session metadata via subsession.sync', () => { }, })); + expect(captured[0].transportPendingMessages).toEqual(['queued one', 'queued two']); + + act(() => send({ + type: 'timeline.event', + event: { + type: 'session.state', + sessionId: 'deck_sub_q4', + payload: { state: 'idle' }, + }, + })); + expect(captured[0].transportPendingMessages).toEqual([]); }); }); diff --git a/web/test/ws-client.test.ts b/web/test/ws-client.test.ts index 8f9d455fd..f6e204b58 100644 --- a/web/test/ws-client.test.ts +++ b/web/test/ws-client.test.ts @@ -332,6 +332,17 @@ describe('WsClient', () => { expect(handler).toHaveBeenCalledWith({ type: DAEMON_MSG.UPGRADE_BLOCKED, reason: 'p2p_active', activeRunIds: ['run_1'] }); client.disconnect(); }); + + it('dispatches daemon.upgrade_blocked transport_busy to handlers', async () => { + const client = await connectClient(); + const handler = vi.fn(); + client.onMessage(handler); + handler.mockClear(); + + lastWs!.emit('message', { data: JSON.stringify({ type: DAEMON_MSG.UPGRADE_BLOCKED, reason: 'transport_busy', activeSessionNames: ['deck_proj_brain'] }) }); + expect(handler).toHaveBeenCalledWith({ type: DAEMON_MSG.UPGRADE_BLOCKED, reason: 'transport_busy', activeSessionNames: ['deck_proj_brain'] }); + client.disconnect(); + }); }); // ── fsListDir ─────────────────────────────────────────────────────────