From ba98d1263b609617041bdfd3284975207452ec8d Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 14:29:03 +0800 Subject: [PATCH 1/6] fix codex websearch labels and default pgvector image --- README.i18n/README.es.md | 2 +- README.i18n/README.ja.md | 2 +- README.i18n/README.ko.md | 2 +- README.i18n/README.ru.md | 2 +- README.i18n/README.zh-CN.md | 2 +- README.i18n/README.zh-TW.md | 2 +- README.md | 8 ++--- docker-compose.yml | 2 +- landing/index.html | 16 ++++----- .../db/migrations/042_pgvector_embeddings.sql | 2 +- server/test/setup/integration-global.ts | 2 +- src/agent/providers/codex-sdk.ts | 15 +++++--- src/setup/templates.ts | 2 +- test/agent/codex-sdk-provider.test.ts | 34 +++++++++++++++++++ 14 files changed, 66 insertions(+), 27 deletions(-) diff --git a/README.i18n/README.es.md b/README.i18n/README.es.md index b905d34b..f10e855c 100644 --- a/README.i18n/README.es.md +++ b/README.i18n/README.es.md @@ -262,7 +262,7 @@ git clone https://github.com/im4codes/imcodes.git && cd imcodes docker compose up -d ``` -El `docker-compose.yml` generado ya usa `pgvector/pgvector:pg16` para PostgreSQL. +El `docker-compose.yml` generado ya usa `pgvector/pgvector:pg18` para PostgreSQL. ## Windows (experimental) diff --git a/README.i18n/README.ja.md b/README.i18n/README.ja.md index 987f6da7..7ce13e6d 100644 --- a/README.i18n/README.ja.md +++ b/README.i18n/README.ja.md @@ -255,7 +255,7 @@ git clone https://github.com/im4codes/imcodes.git && cd imcodes docker compose up -d ``` -生成される `docker-compose.yml` は PostgreSQL に `pgvector/pgvector:pg16` を使用します。 +生成される `docker-compose.yml` は PostgreSQL に `pgvector/pgvector:pg18` を使用します。 ## Windows(実験的) diff --git a/README.i18n/README.ko.md b/README.i18n/README.ko.md index 7d85c8a6..e6bf2eba 100644 --- a/README.i18n/README.ko.md +++ b/README.i18n/README.ko.md @@ -255,7 +255,7 @@ git clone https://github.com/im4codes/imcodes.git && cd imcodes docker compose up -d ``` -생성되는 `docker-compose.yml` 은 PostgreSQL 이미지로 `pgvector/pgvector:pg16` 을 사용합니다. +생성되는 `docker-compose.yml` 은 PostgreSQL 이미지로 `pgvector/pgvector:pg18` 을 사용합니다. ## Windows (실험적) diff --git a/README.i18n/README.ru.md b/README.i18n/README.ru.md index 7d85a9b8..62b148ec 100644 --- a/README.i18n/README.ru.md +++ b/README.i18n/README.ru.md @@ -255,7 +255,7 @@ git clone https://github.com/im4codes/imcodes.git && cd imcodes docker compose up -d ``` -Сгенерированный `docker-compose.yml` уже использует `pgvector/pgvector:pg16` для PostgreSQL. +Сгенерированный `docker-compose.yml` уже использует `pgvector/pgvector:pg18` для PostgreSQL. ## Windows (экспериментально) diff --git a/README.i18n/README.zh-CN.md b/README.i18n/README.zh-CN.md index d71d84e3..20d0d3fb 100644 --- a/README.i18n/README.zh-CN.md +++ b/README.i18n/README.zh-CN.md @@ -299,7 +299,7 @@ git clone https://github.com/im4codes/imcodes.git && cd imcodes docker compose up -d ``` -生成的 `docker-compose.yml` 已经默认使用 `pgvector/pgvector:pg16` 作为 PostgreSQL 镜像。 +生成的 `docker-compose.yml` 已经默认使用 `pgvector/pgvector:pg18` 作为 PostgreSQL 镜像。 然后访问 `https://your-domain`,使用 `admin` 和打印出来的密码登录。之后使用 `imcodes bind` 绑定你的开发机。 diff --git a/README.i18n/README.zh-TW.md b/README.i18n/README.zh-TW.md index 6eeb88c7..3657786b 100644 --- a/README.i18n/README.zh-TW.md +++ b/README.i18n/README.zh-TW.md @@ -299,7 +299,7 @@ git clone https://github.com/im4codes/imcodes.git && cd imcodes docker compose up -d ``` -產生的 `docker-compose.yml` 已經預設使用 `pgvector/pgvector:pg16` 作為 PostgreSQL 映像。 +產生的 `docker-compose.yml` 已經預設使用 `pgvector/pgvector:pg18` 作為 PostgreSQL 映像。 然后访问 `https://your-domain`,使用 `admin` 和打印出来的密码登录。之后使用 `imcodes bind` 绑定你的开发机。 diff --git a/README.md b/README.md index 4b7c42eb..1b8df3a2 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ IM.codes gives coding agents one shared memory layer across providers. It turns ### Breaking Changes -- **PostgreSQL image changed to `pgvector/pgvector:pg16`** (was `postgres:16-alpine`). Required for multilingual vector search in shared agent memory. Existing self-hosted deployments must update their `docker-compose.yml`: +- **PostgreSQL default image changed to `pgvector/pgvector:pg18`** (instead of `postgres:16-alpine`). New self-hosted deployments generated from the current templates use this image for multilingual vector search in shared agent memory: ```yaml postgres: - image: pgvector/pgvector:pg16 # was: postgres:16-alpine + image: pgvector/pgvector:pg18 # was: postgres:16-alpine ``` - This is a drop-in replacement — data volumes are fully compatible. The pgvector extension is enabled automatically by the server migration on first startup. + The pgvector extension is enabled automatically by the server migration on first startup. ## Screenshots @@ -309,7 +309,7 @@ git clone https://github.com/im4codes/imcodes.git && cd imcodes docker compose up -d ``` -The generated `docker-compose.yml` already uses `pgvector/pgvector:pg16` for PostgreSQL. +The generated `docker-compose.yml` already uses `pgvector/pgvector:pg18` for PostgreSQL. Login at `https://your-domain` with `admin` and the printed password. Bind your dev machine with `imcodes bind`. diff --git a/docker-compose.yml b/docker-compose.yml index 0ff62287..72706987 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: postgres: - image: pgvector/pgvector:pg16 + image: pgvector/pgvector:pg18 restart: unless-stopped environment: POSTGRES_DB: imcodes diff --git a/landing/index.html b/landing/index.html index b1ad7d61..defa4534 100644 --- a/landing/index.html +++ b/landing/index.html @@ -418,7 +418,7 @@

self-host

git clone https://github.com/im4codes/imcodes.git && cd imcodes ./gen-env.sh imc.example.com # generates .env, prints admin password docker compose up -d
-

Generated docker-compose.yml uses pgvector/pgvector:pg16 for PostgreSQL.

+

Generated docker-compose.yml uses pgvector/pgvector:pg18 for PostgreSQL.

@@ -513,7 +513,7 @@

about

oc_4: 'Saved to ~/.imcodes/openclaw.json so the daemon can reconnect automatically', oc_5: 'Use imcodes disconnect openclaw to remove the saved config', oc_note: 'Only tested on macOS so far. Remote plain-text ws:// URLs require --insecure.', - sh_manual_pgvector: 'Generated docker-compose.yml uses pgvector/pgvector:pg16 for PostgreSQL.', + sh_manual_pgvector: 'Generated docker-compose.yml uses pgvector/pgvector:pg18 for PostgreSQL.', }, 'zh-CN': { tagline: '给 AI agent 的 IM。共享记忆、受监督执行,以及跨模型审计。', @@ -584,7 +584,7 @@

about

oc_note: '目前只在 macOS 上测试过。远程明文 ws:// 地址需要加 --insecure。', sh_intro: '在一台机器上部署服务端和守护进程。需要 Docker 和指向该服务器的域名。', sh_desc: '自动生成配置、启动 PostgreSQL + 服务端 + Caddy(自动 HTTPS)、创建管理员账户并绑定本地守护进程。凭证在最后打印。', - sh_connect: '连接其他机器:', sh_manual: '手动部署:', sh_manual_pgvector: '生成的 docker-compose.yml 已默认使用 pgvector/pgvector:pg16 作为 PostgreSQL 镜像。', + sh_connect: '连接其他机器:', sh_manual: '手动部署:', sh_manual_pgvector: '生成的 docker-compose.yml 已默认使用 pgvector/pgvector:pg18 作为 PostgreSQL 镜像。', req_os: 'macOS、Linux 或 Windows(原生试验性;WSL 完全支持)', req_mux: 'tmux(Linux/macOS/WSL)。Windows 原生使用 ConPTY(内置)。', about_text: '个人项目。几乎完全由 Claude Code 构建,CodexGemini CLI 也有重要贡献。', @@ -659,7 +659,7 @@

about

oc_note: '目前只在 macOS 上測試過。遠端明文 ws:// 位址需要加上 --insecure。', sh_intro: '在一台機器上部署伺服器和守護程序。需要 Docker 和指向該伺服器的網域。', sh_desc: '自動產生設定、啟動 PostgreSQL + 伺服器 + Caddy(自動 HTTPS)、建立管理員帳戶並綁定本地守護程序。憑證在最後列印。', - sh_connect: '連接其他機器:', sh_manual: '手動部署:', sh_manual_pgvector: '產生的 docker-compose.yml 已經預設使用 pgvector/pgvector:pg16 作為 PostgreSQL 映像。', + sh_connect: '連接其他機器:', sh_manual: '手動部署:', sh_manual_pgvector: '產生的 docker-compose.yml 已經預設使用 pgvector/pgvector:pg18 作為 PostgreSQL 映像。', req_os: 'macOS、Linux 或 Windows(原生試驗性;WSL 完全支援)', req_mux: 'tmux(Linux/macOS/WSL)。Windows 原生使用 ConPTY(內建)。', about_text: '個人專案。幾乎完全由 Claude Code 建構,CodexGemini CLI 也有重要貢獻。', @@ -734,7 +734,7 @@

about

oc_note: 'Only tested on macOS so far. Remote plain-text ws:// URLs require --insecure.', sh_intro: '1台のマシンにサーバーとデーモンをデプロイ。DockerとDNS設定済みのドメインが必要です。', sh_desc: '設定を自動生成、PostgreSQL + サーバー + Caddy(自動HTTPS)を起動、管理者アカウントを作成しローカルデーモンをバインド。認証情報は最後に表示されます。', - sh_connect: '追加マシンの接続:', sh_manual: '手動セットアップ:', sh_manual_pgvector: '生成される docker-compose.yml は PostgreSQL に pgvector/pgvector:pg16 を使用します。', + sh_connect: '追加マシンの接続:', sh_manual: '手動セットアップ:', sh_manual_pgvector: '生成される docker-compose.yml は PostgreSQL に pgvector/pgvector:pg18 を使用します。', req_os: 'macOS、Linux、または Windows(ネイティブは実験的、WSL は完全対応)', req_mux: 'tmux(Linux/macOS/WSL)。Windows ネイティブは ConPTY(組み込み)を使用。', about_text: '個人プロジェクト。ほぼ全てを Claude Code が構築、CodexGemini CLI も重要な貢献。', @@ -809,7 +809,7 @@

about

oc_note: 'Only tested on macOS so far. Remote plain-text ws:// URLs require --insecure.', sh_intro: '단일 머신에 서버와 데몬을 배포합니다. Docker와 서버를 가리키는 도메인이 필요합니다.', sh_desc: '모든 설정 자동 생성, PostgreSQL + 서버 + Caddy(자동 HTTPS) 시작, 관리자 계정 생성 및 로컬 데몬 바인딩. 자격 증명은 마지막에 출력됩니다.', - sh_connect: '추가 머신 연결:', sh_manual: '수동 설정:', sh_manual_pgvector: '생성되는 docker-compose.yml 은 PostgreSQL 이미지로 pgvector/pgvector:pg16 을 사용합니다.', + sh_connect: '추가 머신 연결:', sh_manual: '수동 설정:', sh_manual_pgvector: '생성되는 docker-compose.yml 은 PostgreSQL 이미지로 pgvector/pgvector:pg18 을 사용합니다.', req_os: 'macOS, Linux 또는 Windows (네이티브 실험적; WSL 완전 지원)', req_mux: 'tmux (Linux/macOS/WSL). Windows 네이티브는 ConPTY (기본 내장) 사용.', about_text: '개인 프로젝트. 거의 전적으로 Claude Code가 구축, CodexGemini CLI도 중요한 기여.', @@ -884,7 +884,7 @@

about

oc_note: 'Only tested on macOS so far. Remote plain-text ws:// URLs require --insecure.', sh_intro: 'Despliega servidor + daemon en una sola máquina. Requiere Docker y un dominio con DNS apuntando al servidor.', sh_desc: 'Genera toda la configuración, inicia PostgreSQL + servidor + Caddy con HTTPS automático, crea la cuenta admin y vincula el daemon local. Las credenciales se imprimen al final.', - sh_connect: 'Para conectar máquinas adicionales:', sh_manual: 'Configuración manual:', sh_manual_pgvector: 'El docker-compose.yml generado ya usa pgvector/pgvector:pg16 para PostgreSQL.', + sh_connect: 'Para conectar máquinas adicionales:', sh_manual: 'Configuración manual:', sh_manual_pgvector: 'El docker-compose.yml generado ya usa pgvector/pgvector:pg18 para PostgreSQL.', req_os: 'macOS, Linux o Windows (nativo experimental; WSL totalmente soportado)', req_mux: 'tmux (Linux/macOS/WSL). Windows nativo usa ConPTY (integrado).', about_text: 'Proyecto personal. Construido casi en su totalidad por Claude Code, con contribuciones de Codex y Gemini CLI.', @@ -959,7 +959,7 @@

about

oc_note: 'Only tested on macOS so far. Remote plain-text ws:// URLs require --insecure.', sh_intro: 'Разверните сервер + демон на одной машине. Требуется Docker и домен с DNS, указывающим на сервер.', sh_desc: 'Автоматически генерирует конфигурацию, запускает PostgreSQL + сервер + Caddy с автоматическим HTTPS, создаёт учётную запись администратора и привязывает локальный демон. Данные для входа выводятся в конце.', - sh_connect: 'Подключение дополнительных машин:', sh_manual: 'Ручная настройка:', sh_manual_pgvector: 'Сгенерированный docker-compose.yml уже использует pgvector/pgvector:pg16 для PostgreSQL.', + sh_connect: 'Подключение дополнительных машин:', sh_manual: 'Ручная настройка:', sh_manual_pgvector: 'Сгенерированный docker-compose.yml уже использует pgvector/pgvector:pg18 для PostgreSQL.', req_os: 'macOS, Linux или Windows (нативная поддержка экспериментальная; WSL полностью поддерживается)', req_mux: 'tmux (Linux/macOS/WSL). Нативный Windows использует ConPTY (встроенный).', about_text: 'Личный проект. Почти полностью создан Claude Code, со значительным вкладом Codex и Gemini CLI.', diff --git a/server/src/db/migrations/042_pgvector_embeddings.sql b/server/src/db/migrations/042_pgvector_embeddings.sql index ea4c71d7..3fe0d503 100644 --- a/server/src/db/migrations/042_pgvector_embeddings.sql +++ b/server/src/db/migrations/042_pgvector_embeddings.sql @@ -1,5 +1,5 @@ -- Enable pgvector extension (requires pgvector/pgvector Docker image). --- BREAKING CHANGE: PostgreSQL image must be pgvector/pgvector:pg16 (not postgres:16-alpine). +-- Default compose/template image is pgvector/pgvector:pg18 (not postgres:16-alpine). CREATE EXTENSION IF NOT EXISTS vector; -- Migration 038 created shared_context_embeddings with either vector(1536) or JSONB fallback. diff --git a/server/test/setup/integration-global.ts b/server/test/setup/integration-global.ts index bb2210c5..8817f420 100644 --- a/server/test/setup/integration-global.ts +++ b/server/test/setup/integration-global.ts @@ -11,7 +11,7 @@ import { PostgreSqlContainer } from '@testcontainers/postgresql'; export async function setup() { - const container = await new PostgreSqlContainer('pgvector/pgvector:pg16').start(); + const container = await new PostgreSqlContainer('pgvector/pgvector:pg18').start(); process.env.TEST_DATABASE_URL = container.getConnectionUri(); return async function teardown() { diff --git a/src/agent/providers/codex-sdk.ts b/src/agent/providers/codex-sdk.ts index a3a38b89..5bba2738 100644 --- a/src/agent/providers/codex-sdk.ts +++ b/src/agent/providers/codex-sdk.ts @@ -64,6 +64,11 @@ interface CodexSdkSessionState { } function toolFromItem(item: Record, lifecycle: 'started' | 'completed'): ToolCallEvent | null { + const meaningfulString = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; + }; switch (item.type) { case 'commandExecution': return { @@ -155,11 +160,11 @@ function toolFromItem(item: Record, lifecycle: 'started' | 'complet // `JSON.stringify(input)` — that's where the // `{"query":"","action":{"type":"other"}}` screen artifact came from. const action = item.action as Record | undefined; - const actionType = typeof action?.type === 'string' ? action.type : undefined; - const actionQuery = typeof action?.query === 'string' ? action.query : undefined; - const actionPattern = typeof action?.pattern === 'string' ? action.pattern : undefined; - const actionUrl = typeof action?.url === 'string' ? action.url : undefined; - const topLevelQuery = typeof item.query === 'string' ? item.query : undefined; + const actionType = meaningfulString(action?.type); + const actionQuery = meaningfulString(action?.query); + const actionPattern = meaningfulString(action?.pattern); + const actionUrl = meaningfulString(action?.url); + const topLevelQuery = meaningfulString(item.query); // Pick the single best human-readable label for the flat `input.query` // slot. Priority: explicit query → pattern → url → bracketed action // type (`(other)` / `(open_page)`) for the no-info fallback. The UI diff --git a/src/setup/templates.ts b/src/setup/templates.ts index 5d430974..ec72182d 100644 --- a/src/setup/templates.ts +++ b/src/setup/templates.ts @@ -4,7 +4,7 @@ export function dockerComposeTemplate(opts?: { ghcrPrefix?: string }): string { const ghcr = opts?.ghcrPrefix ?? 'ghcr.io'; return `services: postgres: - image: pgvector/pgvector:pg16 + image: pgvector/pgvector:pg18 restart: unless-stopped environment: POSTGRES_DB: imcodes diff --git a/test/agent/codex-sdk-provider.test.ts b/test/agent/codex-sdk-provider.test.ts index f86ae496..df1e0f58 100644 --- a/test/agent/codex-sdk-provider.test.ts +++ b/test/agent/codex-sdk-provider.test.ts @@ -521,6 +521,40 @@ describe('CodexSdkProvider', () => { expect(input.action).toBeUndefined(); }); + it('ignores empty-string WebSearch query fields and still falls back to action type', async () => { + const provider = new CodexSdkProvider(); + await provider.connect({ binaryPath: 'codex' }); + await provider.createSession({ sessionKey: 'route-websearch-empty-query', cwd: '/tmp/project' }); + + const tools: Array<{ input: unknown; detail?: unknown }> = []; + provider.onToolCall((_, tool) => tools.push({ input: tool.input, detail: tool.detail })); + + await provider.send('route-websearch-empty-query', 'search'); + const child = childProcessMock.children[0]; + child.emits({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + id: 'ws-empty', + type: 'webSearch', + query: '', + action: { type: 'other', query: '' }, + }, + }, + }); + child.emits({ method: 'turn/completed', params: { threadId: 'thread-1', turn: { id: 'turn-1', status: 'completed', error: null } } }); + await flush(); + + expect(tools).toHaveLength(1); + expect(tools[0].input).toEqual({ query: '(other)' }); + const detail = tools[0].detail as { summary?: string; input?: Record; meta?: { actionType?: string } }; + expect(detail.summary).toBe('(other)'); + expect(detail.input).toEqual({ query: '(other)', action: { type: 'other', query: '' } }); + expect(detail.meta?.actionType).toBe('other'); + }); + it('applies thinking level to subsequent Codex SDK turns', async () => { const provider = new CodexSdkProvider(); await provider.connect({ binaryPath: 'codex' }); From 0e2c6234fee6146b26b1e14dd764cbb1cbe6ca18 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 14:55:50 +0800 Subject: [PATCH 2/6] fix: collapse duplicate auto supervision notes --- src/daemon/supervision-automation.ts | 2 +- web/test/components/ChatView.test.tsx | 41 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/daemon/supervision-automation.ts b/src/daemon/supervision-automation.ts index 4262e4d6..d5a39dbb 100644 --- a/src/daemon/supervision-automation.ts +++ b/src/daemon/supervision-automation.ts @@ -464,7 +464,7 @@ class SupervisionAutomation { sessionName, 'assistant.text', { text, streaming: false, automation: true, automationKind: kind, memoryExcluded: true }, - { source: 'daemon', confidence: 'high', eventId: `supervision-note:${kind}:${randomUUID()}` }, + { source: 'daemon', confidence: 'high', eventId: `supervision-note:${sessionName}:${kind}` }, ); } diff --git a/web/test/components/ChatView.test.tsx b/web/test/components/ChatView.test.tsx index 50255712..52e4342a 100644 --- a/web/test/components/ChatView.test.tsx +++ b/web/test/components/ChatView.test.tsx @@ -381,6 +381,47 @@ describe('ChatView', () => { expect(container.querySelectorAll('.chat-assistant-automation')).toHaveLength(1); }); + it('keeps only the latest Auto note when supervision reuses the same event id', async () => { + const events = [ + { + eventId: 'supervision-note:deck_main_brain:supervision-status', + type: 'assistant.text', + ts: 1001, + payload: { + text: 'Auto: checking whether the task is complete...', + streaming: false, + automation: true, + automationKind: 'supervision-status', + }, + }, + { + eventId: 'supervision-note:deck_main_brain:supervision-status', + type: 'assistant.text', + ts: 1002, + payload: { + text: 'Auto: task looks complete.', + streaming: false, + automation: true, + automationKind: 'supervision-status', + }, + }, + ] as any; + + const { container } = render( + , + ); + + expect(chatMarkdownRenderSpy).toHaveBeenCalledTimes(1); + expect(chatMarkdownRenderSpy.mock.calls[0]?.[0]).toBe('Auto: task looks complete.'); + expect(container.textContent).toContain('Auto: task looks complete.'); + expect(container.textContent).not.toContain('Auto: checking whether the task is complete...'); + expect(container.querySelectorAll('.chat-assistant-automation')).toHaveLength(1); + }); + it('renders transport-origin memory.context cards the same as process recall cards', async () => { const { container, getByText } = render( Date: Tue, 21 Apr 2026 15:09:18 +0800 Subject: [PATCH 3/6] fix(supervision): user-set rules override generic heuristics instead of being overridden by them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed failure: user set global rule "Always commit and push if asked!" in the supervision defaults. A session hit idle with uncommitted work; user asked "提交了么?" (did you commit?), agent answered "还没提交" (no). Supervisor returned `complete` — rule was never enforced. Root cause — two heuristics in the decision-prompt rule list were structurally able to defeat any user rule: 1. "A factual answer to a user question ... is typically complete for that turn; the user asked a question, the agent answered it. Do not treat state reports as proposed work." 2. "A user-set supervision rule phrased conditionally ('if asked', 'when X') is conditional. Check whether the condition actually fires in the current turn before using it to justify continue." The arbiter LLM took "Always commit and push if asked!" at heuristic #2's narrowest reading ("the user didn't literally command 'commit it' this turn → condition didn't fire") and combined it with heuristic #1 to justify `complete` on the Q-and-A turn. Result: the user's enforce-this rule was silently downgraded to "advice the arbiter may ignore". Fix — reorder and rewrite: - New top-of-list clause: "USER-SET SUPERVISION RULES ARE AUTHORITATIVE." This is the first decision rule the arbiter reads. It says the user- rules block overrides the generic heuristics below it, gives concrete worked examples for: * commit/push rules (matches the current failure mode verbatim) * blanket wording ("always", "每次", "必须", "绝不") → unconditional * conditional wording ("if asked", "when X", "如果", "当") → interpret GENEROUSLY in the user's favor: the topic appearing in the conversation IS the condition firing. - Heuristic #1 ("factual Q&A → complete") now explicitly reads "typically complete for that turn IF no user-set rule applies" — so it still covers ordinary questions but stops poaching turns that a user rule governs. - Heuristic #2 (the conditional-rule escape hatch) is removed; its responsibility is folded into the authoritative clause, which now owns all conditional-rule handling from the user-rules-always-win side. - Repair prompt mirrors the same clause so JSON-invalid fallbacks can't drop back into the old behavior. All 71 existing supervision prompt / config / broker tests stay green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/daemon/supervision-prompts.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/daemon/supervision-prompts.ts b/src/daemon/supervision-prompts.ts index c4098a76..528536e4 100644 --- a/src/daemon/supervision-prompts.ts +++ b/src/daemon/supervision-prompts.ts @@ -68,9 +68,12 @@ export function buildSupervisionDecisionPrompt( '- nextAction: REQUIRED when decision is continue — imperative instruction for the agent\'s next turn. Must be concrete and executable, e.g. "Run `npm test` and fix any failing spec", "Commit staged changes with message X and push to origin/dev". DO NOT write vague fillers like "keep going", "continue", "finish the task", "继续完成任务" — those are rejected and force-escalated to ask_human.', '- extra: optional object reserved for future metadata; return {} if you have nothing to add.', 'Decision rules:', + '- USER-SET SUPERVISION RULES ARE AUTHORITATIVE. When the user-rules block below contains a directive, it OVERRIDES the generic heuristics in this list. The user set these rules so supervision would enforce them; do not let a generic heuristic provide an escape hatch. Examples of things that must trigger `continue` (not `complete`) despite other heuristics:', + ' * Rule says "Always commit and push if asked!" and the conversation topic is commits / pushing / "uncommitted files" / "did you push" / "还没提交" etc. → if there are uncommitted changes, return `continue` with nextAction = "Stage + commit + push the outstanding changes to origin/".', + ' * Rule uses blanket wording — "always", "every time", "each time", "must", "never skip", "总是", "每次", "必须", "一定", "不要省略", "绝不" — treat as UNCONDITIONAL policy. The mere presence of the relevant topic in the conversation is the trigger; do NOT require the user to have explicitly commanded the action in this exact turn.', + ' * Rule uses conditional wording — "if asked", "when X", "once Y", "如果", "当" — interpret the condition GENEROUSLY in favor of the user\'s intent. A user rule "Always commit and push if asked!" asked about an uncommitted diff counts as the condition firing; an assistant reply reporting "还没提交" counts as the condition firing.', '- Prefer ask_human over a vague continue. If you cannot articulate a concrete nextAction, returning ask_human is the correct move — do not stall by emitting filler continues (they are downgraded to ask_human automatically and just waste a round-trip).', - '- A factual answer to a user question (e.g. "yes, there are 3 uncommitted files") is typically complete for that turn; the user asked a question, the agent answered it. Do not treat state reports as proposed work.', - '- A user-set supervision rule phrased conditionally ("if asked", "when X") is conditional. Check whether the condition actually fires in the current turn before using it to justify continue.', + '- A factual answer to a user question (e.g. "yes, there are 3 uncommitted files") is typically complete for that turn IF no user-set rule applies. If a user rule applies (see authoritative clause above), return continue and enforce the rule. Do not otherwise treat state reports as proposed work.', '- When the assistant itself says remaining implementation work (tests, fixes, commit/push) is still pending, choose continue AND spell out what to do in nextAction.', buildImcodesWorkflowBackgroundSection(), buildCustomInstructionsSection(resolveSupervisionCustomInstructionsDetail(request.snapshot)), @@ -94,6 +97,7 @@ export function buildSupervisionDecisionRepairPrompt( '{"decision":"complete|continue|ask_human","reason":"...","confidence":0.0,"gap":"...","nextAction":"...","extra":{}}', 'When decision is continue, BOTH gap and nextAction are required; nextAction must be a concrete imperative instruction, not a filler like "keep going" / "继续完成任务". If you cannot name a concrete next action, return ask_human instead — a vague continue is always downgraded to ask_human anyway.', 'If the assistant response mentions remaining implementation work like tests, fixes, verification, commit/push, or another concrete next engineering step, return continue with a nextAction that names the exact command or deliverable.', + 'USER-SET SUPERVISION RULES in the block below are AUTHORITATIVE and override the generic heuristics above. If a user rule uses blanket wording ("always", "每次", "必须", "绝不") or applies conditionally to the current topic (a rule like "Always commit and push if asked!" applies whenever the conversation is about committing / pushing / uncommitted changes, even if the user just asked a status question), return `continue` with a nextAction that enforces the rule. Do not treat a factual answer as `complete` when it violates a user-set rule.', buildImcodesWorkflowBackgroundSection(), buildCustomInstructionsSection(resolveSupervisionCustomInstructionsDetail(request.snapshot)), 'Previous invalid output:', From 959cd5f5945effcdc54db018d1f4f9acf38fbb5e Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 15:35:52 +0800 Subject: [PATCH 4/6] fix: upload oversized pasted chat text as attachments --- web/src/components/SessionControls.tsx | 68 ++++++++---- web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/es.json | 2 + web/src/i18n/locales/ja.json | 2 + web/src/i18n/locales/ko.json | 2 + web/src/i18n/locales/ru.json | 2 + web/src/i18n/locales/zh-CN.json | 2 + web/src/i18n/locales/zh-TW.json | 2 + web/test/components/SessionControls.test.tsx | 103 +++++++++++++++++++ 9 files changed, 167 insertions(+), 18 deletions(-) diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 3ff15c8b..4e62da4e 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -118,6 +118,13 @@ interface Props { type MenuAction = 'restart' | 'new' | 'stop'; type ModelChoice = 'opus[1M]' | 'sonnet' | 'haiku'; + +const INLINE_PASTE_TEXT_CHAR_LIMIT = 12000; + +function buildPastedTextFileName(now = new Date()): string { + const compact = now.toISOString().replace(/[:.]/g, '-'); + return `pasted-text-${compact}.txt`; +} type CodexModelChoice = 'gpt-5.4' | 'gpt-5.4-mini' | 'gpt-5.2'; type QwenModelChoice = string; type P2pMode = string; // 'solo' | single modes | combo pipelines like 'brainstorm>discuss>plan' | typeof P2P_CONFIG_MODE @@ -1743,30 +1750,17 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on if (document.body.scrollTop !== 0) document.body.scrollTop = 0; }; - // Paste: upload files from clipboard, or insert plain text - const handlePaste = (e: Event) => { - const ce = e as ClipboardEvent; - const files = ce.clipboardData?.files; - if (files && files.length > 0) { - e.preventDefault(); - void handleFileUpload(files); - return; - } - e.preventDefault(); - const text = ce.clipboardData?.getData('text/plain') ?? ''; - document.execCommand('insertText', false, text); - setHasText(!!(divRef.current?.textContent?.trim())); - }; - - const handleFileUpload = useCallback(async (files: FileList | null) => { - if (!files || files.length === 0 || !serverId) return; + const uploadAttachmentFiles = useCallback(async (files: readonly File[]): Promise => { + if (files.length === 0 || !serverId) return false; setUploading(true); setUploadProgress(0); setUploadError(null); - for (const file of Array.from(files)) { + let uploadedAny = false; + for (const file of files) { try { const result = await uploadFile(serverId, file, (pct) => setUploadProgress(pct)); if (result.attachment?.daemonPath) { + uploadedAny = true; setAttachments((prev) => [...prev, { path: result.attachment!.daemonPath, name: file.name }]); } } catch (err) { @@ -1783,8 +1777,46 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on } } setUploading(false); + return uploadedAny; }, [serverId, t]); + const handleFileUpload = useCallback(async (files: FileList | null) => { + if (!files || files.length === 0) return; + await uploadAttachmentFiles(Array.from(files)); + }, [uploadAttachmentFiles]); + + // Paste: upload files from clipboard, or insert plain text + const handlePaste = (e: Event) => { + const ce = e as ClipboardEvent; + const files = ce.clipboardData?.files; + if (files && files.length > 0) { + e.preventDefault(); + void handleFileUpload(files); + return; + } + e.preventDefault(); + const text = ce.clipboardData?.getData('text/plain') ?? ''; + if (!text) return; + if (text.length > INLINE_PASTE_TEXT_CHAR_LIMIT) { + if (!serverId) { + showSendWarning(t('upload.long_text_requires_attachment')); + return; + } + const fileName = buildPastedTextFileName(); + const textFile = new File([text], fileName, { type: 'text/plain' }); + void (async () => { + const uploaded = await uploadAttachmentFiles([textFile]); + if (uploaded) { + showSendWarning(t('upload.long_text_attached', { name: fileName })); + divRef.current?.focus(); + } + })(); + return; + } + document.execCommand('insertText', false, text); + setHasText(!!(divRef.current?.textContent?.trim())); + }; + const handleShortcut = (data: string) => { if (!ws || !activeSession) return; ws.sendInput(activeSession.name, data); diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index fbecbf84..5a4b6d9c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -635,6 +635,8 @@ "uploading": "Uploading...", "upload_failed": "Upload failed", "file_too_large": "File too large (max {{max}}MB)", + "long_text_attached": "Large pasted text attached as {{name}}", + "long_text_requires_attachment": "Paste is too large for inline input here. Upload it as a file instead.", "download_file": "Download", "downloading": "Downloading...", "preview_unavailable": "Preview unavailable", diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index f1db87d1..783422af 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -634,6 +634,8 @@ "uploading": "Subiendo...", "upload_failed": "Error al subir", "file_too_large": "Archivo demasiado grande (máx. {{max}}MB)", + "long_text_attached": "El texto pegado largo se adjuntó como {{name}}", + "long_text_requires_attachment": "Este pegado es demasiado grande para el cuadro de texto. Súbelo como archivo.", "download_file": "Descargar", "downloading": "Descargando...", "preview_unavailable": "Vista previa no disponible", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index a8a9a60d..6530ed4c 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -634,6 +634,8 @@ "uploading": "アップロード中...", "upload_failed": "アップロード失敗", "file_too_large": "ファイルが大きすぎます(最大 {{max}}MB)", + "long_text_attached": "長い貼り付けテキストを {{name}} として添付しました", + "long_text_requires_attachment": "この貼り付け内容は入力欄に直接入れるには長すぎます。ファイルとしてアップロードしてください。", "download_file": "ダウンロード", "downloading": "ダウンロード中...", "preview_unavailable": "プレビューできません", diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index 1543f007..ce32ced6 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -634,6 +634,8 @@ "uploading": "업로드 중...", "upload_failed": "업로드 실패", "file_too_large": "파일이 너무 큽니다 (최대 {{max}}MB)", + "long_text_attached": "긴 붙여넣기 텍스트가 {{name}} 첨부파일로 추가되었습니다", + "long_text_requires_attachment": "이 붙여넣기 내용은 입력창에 직접 넣기엔 너무 깁니다. 파일로 업로드하세요.", "download_file": "다운로드", "downloading": "다운로드 중...", "preview_unavailable": "미리보기 불가", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 771ed578..c8a6bc93 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -634,6 +634,8 @@ "uploading": "Загрузка...", "upload_failed": "Ошибка загрузки", "file_too_large": "Файл слишком большой (макс. {{max}}МБ)", + "long_text_attached": "Большой вставленный текст прикреплен как {{name}}", + "long_text_requires_attachment": "Эта вставка слишком большая для поля ввода. Загрузите ее как файл.", "download_file": "Скачать", "downloading": "Скачивание...", "preview_unavailable": "Предпросмотр недоступен", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index a506c016..5f0ad3c5 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -635,6 +635,8 @@ "uploading": "上传中...", "upload_failed": "上传失败", "file_too_large": "文件过大(最大 {{max}}MB)", + "long_text_attached": "超长粘贴内容已作为 {{name}} 附件添加", + "long_text_requires_attachment": "这段粘贴内容太长,不能直接放进输入框,请改为文件上传。", "download_file": "下载", "downloading": "下载中...", "preview_unavailable": "无法预览", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 0d1ad966..90519390 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -635,6 +635,8 @@ "uploading": "上傳中...", "upload_failed": "上傳失敗", "file_too_large": "檔案過大(最大 {{max}}MB)", + "long_text_attached": "過長貼上內容已作為 {{name}} 附件加入", + "long_text_requires_attachment": "這段貼上內容太長,不能直接放進輸入框,請改成檔案上傳。", "download_file": "下載", "downloading": "下載中...", "preview_unavailable": "無法預覽", diff --git a/web/test/components/SessionControls.test.tsx b/web/test/components/SessionControls.test.tsx index 32a1f503..9e503cfd 100644 --- a/web/test/components/SessionControls.test.tsx +++ b/web/test/components/SessionControls.test.tsx @@ -56,6 +56,12 @@ vi.mock('react-i18next', () => ({ if (key === 'session.send_placeholder_desktop_upload') { return `${String(opts?.placeholder ?? '')} Supports fast multi-file paste upload`; } + if (key === 'upload.long_text_attached') { + return `Large pasted text attached as ${String(opts?.name ?? '')}`; + } + if (key === 'upload.long_text_requires_attachment') { + return 'Paste is too large for inline input here. Upload it as a file instead.'; + } if (key === 'session.stop_plain') return 'Stop'; if (key === 'session.supervision.quickLabel') return 'Auto'; if (key === 'session.supervision.quickTitle') return 'Auto mode'; @@ -139,6 +145,7 @@ vi.mock('../../src/components/AtPicker.js', () => ({ })); const uploadFileMock = vi.fn(); +const execCommandMock = vi.fn(() => true); const getUserPrefMock = vi.fn().mockResolvedValue(null); const saveUserPrefMock = vi.fn().mockResolvedValue(undefined); const fetchSupervisorDefaultsMock = vi.fn().mockResolvedValue(null); @@ -275,6 +282,17 @@ afterEach(() => { beforeEach(() => { vi.clearAllMocks(); + execCommandMock.mockImplementation((_command: string, _ui?: boolean, value?: string) => { + const active = document.activeElement as HTMLDivElement | null; + if (active && typeof active.textContent === 'string') { + active.textContent = `${active.textContent}${String(value ?? '')}`; + } + return true; + }); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommandMock, + }); sessionStorage.clear(); localStorage.clear(); fetchSupervisorDefaultsMock.mockResolvedValue(null); @@ -2407,6 +2425,91 @@ afterEach(() => { expect(document.querySelector('.controls-input')?.getAttribute('data-placeholder')).toBe('Send to my-project…'); }); + it('keeps normal plain-text paste inline for short clipboard content', () => { + render( + , + ); + + const input = screen.getByRole('textbox') as HTMLDivElement; + input.focus(); + fireEvent.paste(input, { + clipboardData: { + getData: (type: string) => type === 'text/plain' ? 'short inline paste' : '', + }, + }); + + expect(execCommandMock).toHaveBeenCalledWith('insertText', false, 'short inline paste'); + expect(input.textContent).toBe('short inline paste'); + expect(uploadFileMock).not.toHaveBeenCalled(); + }); + + it('converts oversized plain-text paste into an attachment upload', async () => { + uploadFileMock.mockResolvedValue({ attachment: { daemonPath: '/tmp/pasted-text.txt' } }); + const ws = makeWs(); + render( + , + ); + + const input = screen.getByRole('textbox') as HTMLDivElement; + input.focus(); + const longText = 'x'.repeat(13000); + fireEvent.paste(input, { + clipboardData: { + getData: (type: string) => type === 'text/plain' ? longText : '', + }, + }); + + await waitFor(() => expect(uploadFileMock).toHaveBeenCalledTimes(1)); + const uploadedFile = uploadFileMock.mock.calls[0]?.[1] as File; + expect(uploadedFile).toBeInstanceOf(File); + expect(uploadedFile.name).toMatch(/^pasted-text-.*\.txt$/); + expect(await uploadedFile.text()).toBe(longText); + expect(execCommandMock).not.toHaveBeenCalled(); + expect(input.textContent).toBe(''); + await waitFor(() => { + expect(document.querySelector('.attachment-badge-name')?.textContent).toMatch(/^pasted-text-.*\.txt$/); + }); + + fireEvent.click(screen.getByRole('button', { name: /send/i })); + expectSendPayload(ws, { + sessionName: 'my-session', + text: '@/tmp/pasted-text.txt', + }); + }); + + it('blocks oversized plain-text paste when upload context is unavailable', async () => { + render( + , + ); + + const input = screen.getByRole('textbox') as HTMLDivElement; + input.focus(); + fireEvent.paste(input, { + clipboardData: { + getData: (type: string) => type === 'text/plain' ? 'y'.repeat(13000) : '', + }, + }); + + expect(uploadFileMock).not.toHaveBeenCalled(); + expect(execCommandMock).not.toHaveBeenCalled(); + expect(input.textContent).toBe(''); + expect(await screen.findByText('Paste is too large for inline input here. Upload it as a file instead.')).toBeDefined(); + }); + // TODO: fix — file upload mock doesn't trigger state update in jsdom describe.skip('attachment badges', () => { it('shows badge after file upload', async () => { From f1c84f2c26891f428fbe2ad1ce68cc48b7d4a3e2 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 15:40:38 +0800 Subject: [PATCH 5/6] fix: lower pasted text attachment threshold --- web/src/components/SessionControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 4e62da4e..7f7aded0 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -119,7 +119,7 @@ interface Props { type MenuAction = 'restart' | 'new' | 'stop'; type ModelChoice = 'opus[1M]' | 'sonnet' | 'haiku'; -const INLINE_PASTE_TEXT_CHAR_LIMIT = 12000; +const INLINE_PASTE_TEXT_CHAR_LIMIT = 1200; function buildPastedTextFileName(now = new Date()): string { const compact = now.toISOString().replace(/[:.]/g, '-'); From 0a752ed874d93db88dc38d367ad4629db95a771e Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 15:49:47 +0800 Subject: [PATCH 6/6] fix: dedupe supervision auto notes --- src/daemon/supervision-automation.ts | 2 +- test/daemon/claude-no-text-refresh.test.ts | 2 +- test/daemon/supervision-automation.test.ts | 31 ++++++++++++++++++++ web/test/components/ChatView.test.tsx | 6 ++-- web/test/components/SessionControls.test.tsx | 11 ++++++- 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/daemon/supervision-automation.ts b/src/daemon/supervision-automation.ts index d5a39dbb..b3e5bac8 100644 --- a/src/daemon/supervision-automation.ts +++ b/src/daemon/supervision-automation.ts @@ -464,7 +464,7 @@ class SupervisionAutomation { sessionName, 'assistant.text', { text, streaming: false, automation: true, automationKind: kind, memoryExcluded: true }, - { source: 'daemon', confidence: 'high', eventId: `supervision-note:${sessionName}:${kind}` }, + { source: 'daemon', confidence: 'high', eventId: `supervision-note:${sessionName}` }, ); } diff --git a/test/daemon/claude-no-text-refresh.test.ts b/test/daemon/claude-no-text-refresh.test.ts index edaa61ed..9f613b3a 100644 --- a/test/daemon/claude-no-text-refresh.test.ts +++ b/test/daemon/claude-no-text-refresh.test.ts @@ -74,7 +74,7 @@ function assistantText(text: string): string { })}\n`; } -async function waitUntil(fn: () => boolean, timeoutMs = 4000): Promise { +async function waitUntil(fn: () => boolean, timeoutMs = 10000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (fn()) return; diff --git a/test/daemon/supervision-automation.test.ts b/test/daemon/supervision-automation.test.ts index 03422599..d420de40 100644 --- a/test/daemon/supervision-automation.test.ts +++ b/test/daemon/supervision-automation.test.ts @@ -264,6 +264,37 @@ describe('SupervisionAutomation', () => { ])); }); + it('reuses a single visible Auto note id across supervision status transitions', async () => { + const snapshot = await seedSession('supervised'); + + supervisionAutomation.init(); + supervisionAutomation.registerTaskIntent('deck_supervision_brain', 'cmd-note-id', 'implement the feature', snapshot); + beginRun('cmd-note-id', 'implement the feature'); + + completeTurn('implemented the feature'); + await sleep(25); + + const noteEvents = timelineEmitter + .replay('deck_supervision_brain', 0) + .events + .filter((event) => event.type === 'assistant.text' && event.payload.automation === true); + + expect(noteEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + eventId: 'supervision-note:deck_supervision_brain', + payload: expect.objectContaining({ + text: 'Auto: checking whether the task is complete...', + }), + }), + expect.objectContaining({ + eventId: 'supervision-note:deck_supervision_brain', + payload: expect.objectContaining({ + text: 'Auto: task looks complete.', + }), + }), + ])); + }); + it('updates an in-flight run to the latest supervision snapshot when Auto settings change live', async () => { const supervised = await seedSession('supervised'); const upgraded = normalizeSessionSupervisionSnapshot({ diff --git a/web/test/components/ChatView.test.tsx b/web/test/components/ChatView.test.tsx index 52e4342a..45937fb7 100644 --- a/web/test/components/ChatView.test.tsx +++ b/web/test/components/ChatView.test.tsx @@ -384,7 +384,7 @@ describe('ChatView', () => { it('keeps only the latest Auto note when supervision reuses the same event id', async () => { const events = [ { - eventId: 'supervision-note:deck_main_brain:supervision-status', + eventId: 'supervision-note:deck_main_brain', type: 'assistant.text', ts: 1001, payload: { @@ -395,14 +395,14 @@ describe('ChatView', () => { }, }, { - eventId: 'supervision-note:deck_main_brain:supervision-status', + eventId: 'supervision-note:deck_main_brain', type: 'assistant.text', ts: 1002, payload: { text: 'Auto: task looks complete.', streaming: false, automation: true, - automationKind: 'supervision-status', + automationKind: 'supervision-complete', }, }, ] as any; diff --git a/web/test/components/SessionControls.test.tsx b/web/test/components/SessionControls.test.tsx index 9e503cfd..e7314803 100644 --- a/web/test/components/SessionControls.test.tsx +++ b/web/test/components/SessionControls.test.tsx @@ -178,6 +178,15 @@ import { TRANSPORT_MSG } from '@shared/transport-events.js'; const flushAsync = () => new Promise((resolve) => setTimeout(resolve, 0)); +function readBlobText(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error('Failed to read blob text')); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.readAsText(blob); + }); +} + const TEST_OPENSPEC_ADVANCED_ROUNDS = [ { id: 'initial_audit', @@ -2473,7 +2482,7 @@ afterEach(() => { const uploadedFile = uploadFileMock.mock.calls[0]?.[1] as File; expect(uploadedFile).toBeInstanceOf(File); expect(uploadedFile.name).toMatch(/^pasted-text-.*\.txt$/); - expect(await uploadedFile.text()).toBe(longText); + expect(await readBlobText(uploadedFile)).toBe(longText); expect(execCommandMock).not.toHaveBeenCalled(); expect(input.textContent).toBe(''); await waitFor(() => {