From 3edeb8cdfa0be26ec8de2a3e3a0de52a04db90f5 Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Sun, 17 May 2026 23:28:09 +0800 Subject: [PATCH] Reduce packaged app size (#1026) --- .../providers/provider-runtime-sync.ts | 2 +- electron/utils/browser-oauth.ts | 2 +- electron/utils/channel-config.ts | 15 +- electron/utils/openclaw-auth.ts | 26 +-- .../rules/packaged-runtime-pruning-guards.md | 24 +++ .../scenarios/plugin-lifecycle-management.md | 2 + .../specs/tasks/hard-delete-session-jsonl.md | 7 +- .../tasks/packaged-runtime-pruning-guards.md | 40 ++++ package.json | 33 ++-- pnpm-lock.yaml | 185 +++++++++++++----- scripts/after-pack.cjs | 88 ++++++++- scripts/bundle-openclaw.mjs | 66 +++++++ scripts/openclaw-bundle-config.mjs | 5 + src/stores/chat.ts | 79 +++++++- src/stores/chat/helpers.ts | 68 +++++++ src/stores/chat/history-actions.ts | 13 +- src/stores/chat/runtime-send-actions.ts | 2 + src/stores/chat/session-actions.ts | 3 +- tests/unit/after-pack-cleanup.test.ts | 65 ++++++ tests/unit/channel-config.test.ts | 4 +- tests/unit/chat-history-actions.test.ts | 1 + tests/unit/chat-store-history-retry.test.ts | 129 ++++++++++++ tests/unit/openclaw-auth.test.ts | 6 +- tests/unit/openclaw-bundle-config.test.ts | 1 + tests/unit/provider-runtime-sync.test.ts | 4 +- tests/unit/sanitize-config.test.ts | 6 +- 26 files changed, 760 insertions(+), 116 deletions(-) create mode 100644 harness/specs/rules/packaged-runtime-pruning-guards.md create mode 100644 harness/specs/tasks/packaged-runtime-pruning-guards.md create mode 100644 tests/unit/after-pack-cleanup.test.ts diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 4ba790e..7a7be5f 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -25,7 +25,7 @@ import { listAgentsSnapshot } from '../../utils/agent-config'; const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli'; const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`; const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex'; -const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.4`; +const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.5`; /** * Provider types that are not in the built-in provider registry (no `providerConfig.api`). diff --git a/electron/utils/browser-oauth.ts b/electron/utils/browser-oauth.ts index 9425958..12e1950 100644 --- a/electron/utils/browser-oauth.ts +++ b/electron/utils/browser-oauth.ts @@ -12,7 +12,7 @@ export type BrowserOAuthProviderType = 'google' | 'openai'; const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli'; const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview'; const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex'; -const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.4'; +const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.5'; class BrowserOAuthManager extends EventEmitter { private activeProvider: BrowserOAuthProviderType | null = null; diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 2ae99b0..86bbbb2 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -517,8 +517,6 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: enabled: true, entries: { [feishuPluginId]: { enabled: true }, - // Disable the built-in feishu plugin when using openclaw-lark - ...(feishuPluginId !== 'feishu' ? { feishu: { enabled: false } } : {}), } }; } else { @@ -539,15 +537,10 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: if (!currentConfig.plugins.entries) { currentConfig.plugins.entries = {}; } - // Remove conflicting feishu plugin entries; keep only the resolved plugin id. - // When the resolved plugin id is NOT 'feishu', explicitly disable the - // built-in feishu plugin (OpenClaw ships one in dist/extensions/feishu/) - // to prevent it from conflicting with the official openclaw-lark plugin. - if (feishuPluginId !== 'feishu') { - currentConfig.plugins.entries['feishu'] = { enabled: false }; - } else { - delete currentConfig.plugins.entries['feishu']; - } + // Remove conflicting feishu plugin entries; keep only the resolved + // external plugin id. A disabled plugins.entries.feishu record + // blocks openclaw-lark in OpenClaw's gateway startup planner. + delete currentConfig.plugins.entries['feishu']; for (const candidateId of FEISHU_PLUGIN_ID_CANDIDATES) { if (candidateId !== feishuPluginId) { delete currentConfig.plugins.entries[candidateId]; diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index d13a464..63b0fb2 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -516,6 +516,7 @@ const BUNDLED_ALLOWLIST_PRESERVE_IDS = new Set([ 'browser', 'acpx', 'memory-core', + 'codex', ]); const AUTH_PROFILE_PROVIDER_KEY_MAP: Record = { 'openai-codex': 'openai', @@ -2290,32 +2291,25 @@ export async function sanitizeOpenClawConfig(): Promise { } - // ── Disable built-in 'feishu' when official openclaw-lark plugin is active ── - // OpenClaw ships a built-in 'feishu' extension in dist/extensions/feishu/ - // that conflicts with the official @larksuite/openclaw-lark plugin - // (id: 'openclaw-lark'). When the feishu channel IS configured and the - // canonical plugin is NOT the built-in 'feishu' itself, we must: - // 1. Remove bare 'feishu' from plugins.allow - // 2. Explicitly disable the built-in feishu extension + // ── Remove legacy built-in 'feishu' registration ─────────────── + // ClawX bundles Feishu via the official @larksuite/openclaw-lark + // plugin and removes the old built-in dist/extensions/feishu tree. + // Keeping plugins.entries.feishu={enabled:false} looks harmless, but + // OpenClaw's channel startup planner treats it as an explicit blocker + // for the feishu channel owner and skips openclaw-lark at runtime. const allowArr2 = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : []; if (isFeishuConfigured) { const hasCanonicalFeishu = allowArr2.includes(canonicalFeishuId) || !!pEntries[canonicalFeishuId]; if (hasCanonicalFeishu && canonicalFeishuId !== 'feishu') { - // Remove bare 'feishu' from plugins.allow const bareFeishuIdx = allowArr2.indexOf('feishu'); if (bareFeishuIdx !== -1) { allowArr2.splice(bareFeishuIdx, 1); console.log('[sanitize] Removed bare "feishu" from plugins.allow (openclaw-lark plugin is configured)'); modified = true; } - // Explicitly disable the built-in feishu extension so it doesn't - // conflict with the official openclaw-lark plugin at runtime. - // Simply deleting the entry is NOT sufficient — the built-in - // extension in dist/extensions/feishu/ (enabledByDefault: true) will - // still load unless explicitly marked as disabled. - if (!pEntries.feishu || (pEntries.feishu as Record).enabled !== false) { - pEntries.feishu = { enabled: false }; - console.log('[sanitize] Disabled built-in feishu plugin (openclaw-lark plugin is configured)'); + if (pEntries.feishu) { + delete pEntries.feishu; + console.log('[sanitize] Removed legacy plugins.entries.feishu (openclaw-lark plugin is configured)'); modified = true; } } diff --git a/harness/specs/rules/packaged-runtime-pruning-guards.md b/harness/specs/rules/packaged-runtime-pruning-guards.md new file mode 100644 index 0000000..33da399 --- /dev/null +++ b/harness/specs/rules/packaged-runtime-pruning-guards.md @@ -0,0 +1,24 @@ +--- +id: packaged-runtime-pruning-guards +title: Packaged Runtime Pruning Guards +type: ai-coding-rule +appliesTo: + - plugin-lifecycle-management +requiredProfiles: + - fast +--- + +Packaged runtime cleanup must remove only files that are not needed by the +target artifact. + +For macOS universal builds, architecture pruning must preserve both x64 and +arm64 native payloads for the same platform. This includes scoped optional +packages such as `@openai/codex-darwin-x64` and +`@openai/codex-darwin-arm64`, plus per-arch native prebuild directories such +as `tree-sitter-bash/prebuilds/darwin-x64` and +`tree-sitter-bash/prebuilds/darwin-arm64`. + +Any change to `scripts/after-pack.cjs` or `scripts/bundle-openclaw.mjs` that +adds native-package pruning or known-runtime-junk cleanup must include a unit +test covering the target architecture behavior, including `arch: universal` +when the rule can affect macOS packaged builds. diff --git a/harness/specs/scenarios/plugin-lifecycle-management.md b/harness/specs/scenarios/plugin-lifecycle-management.md index a88c73f..ae35431 100644 --- a/harness/specs/scenarios/plugin-lifecycle-management.md +++ b/harness/specs/scenarios/plugin-lifecycle-management.md @@ -36,6 +36,7 @@ requiredRules: - channel-plugin-migration-guards - capability-owner-resolution - active-config-guards + - packaged-runtime-pruning-guards --- Plugin lifecycle management covers bundled and external plugins as one system with different source types. The core model has two layers: @@ -52,5 +53,6 @@ Lifecycle stages: - Validate: ClawX verifies package manifests, dependencies, capability config, ownership uniqueness, and startup requirements. - Activate: only resolved and validated capabilities enter final OpenClaw runtime config. - Recover: failed upgrades, stale registrations, conflicts, and removed channels converge to a single diagnosable state with rollback or cleanup paths. +- Package: cleanup and pruning keep packaged artifacts small without deleting target runtime assets; macOS universal packages keep both x64 and arm64 native payloads. First-stage priority is integration safety: single-owner capability resolution, active config guards, direct regression tests for migration cases, and explicit task specs for resolution, validation, and recovery work. diff --git a/harness/specs/tasks/hard-delete-session-jsonl.md b/harness/specs/tasks/hard-delete-session-jsonl.md index 78499c4..e07d11b 100644 --- a/harness/specs/tasks/hard-delete-session-jsonl.md +++ b/harness/specs/tasks/hard-delete-session-jsonl.md @@ -19,6 +19,7 @@ expectedUserBehavior: - When the pointer's runtimeFile points outside the sessions/ folder (the OPENCLAW_TRAJECTORY_DIR override), that off-disk runtime file is also unlinked so no orphan trajectory remains anywhere on disk. - The session entry is removed from sessions.json so OpenClaw sessions.list stops returning it. - The sidebar list, sessionLabels and sessionLastActivity for the deleted key are cleared in the renderer store. + - Any pending optimistic user message cache for the deleted session key is cleared so a later history reload cannot resurrect deleted chat bubbles. - Token usage history reported by the Dashboard stops including the deleted session. requiredProfiles: - fast @@ -31,6 +32,7 @@ acceptance: - IPC channel name session:delete and HTTP route POST /api/sessions/delete are unchanged in shape. - Both the IPC handler in electron/main/ipc-handlers.ts and the HTTP mirror in electron/api/routes/sessions.ts unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift. - The handler tolerates ENOENT (file already gone) and still updates sessions.json so the sidebar stops listing the entry. + - Renderer delete-session paths clear any in-memory pending optimistic user messages for the deleted key before subsequent history loads run. - agentId from the sessionKey is validated against /^[A-Za-z0-9][A-Za-z0-9_-]*$/ in both surfaces and any sessionFile resolved to a path outside the agent sessions/ directory is refused (defence-in-depth against a corrupt sessions.json). - Absolute-path detection accepts POSIX paths, Windows backslash paths (C:\...) and Windows forward-slash paths (C:/...) so the sweep works on every supported OS. - The sweep also unlinks .trajectory.jsonl and .trajectory-path.json sidecars produced by OpenClaw's runtime trajectory writer. @@ -68,4 +70,7 @@ Renderer surface is untouched: the confirm dialog in `Sidebar.tsx`, the `useChatStore.deleteSession` API, and the host-api/api-client boundary all continue to work without changes. The only user-visible effect is that deleted conversations no longer leave hidden `.deleted.jsonl` files behind -and no longer contribute to Dashboard token-usage history. +and no longer contribute to Dashboard token-usage history. Deletion also +clears renderer-only pending optimistic user messages keyed by the deleted +session so the UI cannot rehydrate a locally staged message after the +transcript and sidebar entry have been removed. diff --git a/harness/specs/tasks/packaged-runtime-pruning-guards.md b/harness/specs/tasks/packaged-runtime-pruning-guards.md new file mode 100644 index 0000000..469c8e7 --- /dev/null +++ b/harness/specs/tasks/packaged-runtime-pruning-guards.md @@ -0,0 +1,40 @@ +--- +id: packaged-runtime-pruning-guards +title: Guard packaged runtime pruning for universal builds +scenario: plugin-lifecycle-management +taskType: plugin-lifecycle +intent: Keep packaged OpenClaw runtime cleanup size-conscious without deleting native payloads required by macOS universal artifacts. +touchedAreas: + - scripts/after-pack.cjs + - scripts/bundle-openclaw.mjs + - scripts/openclaw-bundle-config.mjs + - tests/unit/after-pack-cleanup.test.ts + - tests/unit/openclaw-bundle-config.test.ts + - harness/specs/rules/packaged-runtime-pruning-guards.md + - harness/specs/tasks/packaged-runtime-pruning-guards.md +expectedUserBehavior: + - Packaged OpenAI Codex support works in macOS arm64, x64 and universal artifacts. + - Packaged tree-sitter-bash runtime loading keeps a usable native prebuild for every architecture in the target artifact. + - Size cleanup still removes non-target platform packages and known runtime junk for single-architecture builds. +requiredProfiles: + - fast +requiredTests: + - tests/unit/after-pack-cleanup.test.ts + - tests/unit/openclaw-bundle-config.test.ts +acceptance: + - `cleanupNativePlatformPackages` keeps same-platform x64 and arm64 native packages when the electron-builder arch resolves to `universal`. + - `cleanupNodeModulesRuntimeJunk` keeps same-platform x64 and arm64 `tree-sitter-bash/prebuilds` directories when the target arch is `universal`. + - Non-target platforms are still pruned from scoped native packages and tree-sitter-bash prebuilds. + - Extra bundled OpenClaw packages still include `@openclaw/codex`, and the bundle script still skips a duplicate nested `openclaw` package. +docs: + required: false +--- + +This task captures packaged-runtime cleanup invariants for OpenClaw extension +and native payload bundling. The size optimization path may prune unused +platform binaries, generated declarations, source maps and known non-runtime +files, but it must not treat macOS `universal` as a literal architecture. + +Universal macOS artifacts contain both x64 and arm64 slices. Cleanup helpers +therefore keep same-platform x64 and arm64 packages/prebuilds while still +removing Linux, Windows and other non-target platform payloads. diff --git a/package.json b/package.json index 8e23b26..08c17f7 100644 --- a/package.json +++ b/package.json @@ -77,27 +77,12 @@ "postversion": "node scripts/post-version-push.mjs" }, "dependencies": { - "@monaco-editor/react": "^4.7.0", - "@sinclair/typebox": "^0.34.48", - "@types/diff": "^8.0.0", - "chokidar": "^5.0.0", "clawhub": "^0.5.0", - "diff": "^9.0.0", "electron-store": "^11.0.2", "electron-updater": "^6.8.3", - "katex": "^0.16.45", - "lru-cache": "^11.2.6", - "monaco-editor": "^0.55.1", - "ms": "^2.1.3", "node-machine-id": "^1.1.12", - "pdfjs-dist": "^5.7.284", "posthog-node": "^5.28.0", - "rehype-katex": "^7.0.1", - "remark-frontmatter": "^5.0.0", - "remark-math": "^6.0.0", - "tar": "^6.2.1", - "ws": "^8.19.0", - "xlsx": "^0.18.5" + "ws": "^8.19.0" }, "devDependencies": { "@buape/carbon": "0.16.0", @@ -108,6 +93,8 @@ "@homebridge/ciao": "^1.3.7", "@larksuite/openclaw-lark": "2026.5.12", "@larksuiteoapi/node-sdk": "^1.61.1", + "@monaco-editor/react": "^4.7.0", + "@openclaw/codex": "2026.5.12", "@openclaw/discord": "2026.5.12", "@openclaw/qqbot": "2026.5.12", "@openclaw/whatsapp": "2026.5.12", @@ -124,11 +111,13 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", + "@sinclair/typebox": "^0.34.48", "@soimy/dingtalk": "^3.6.2", "@tencent-connect/qqbot-connector": "^1.1.0", "@tencent-weixin/openclaw-weixin": "^2.4.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/diff": "^8.0.0", "@types/node": "^25.3.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -140,8 +129,10 @@ "@whiskeysockets/baileys": "7.0.0-rc.9", "acpx": "0.5.3", "autoprefixer": "^10.4.24", + "chokidar": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "diff": "^9.0.0", "discord-api-types": "^0.38.47", "electron": "^40.6.0", "electron-builder": "^26.8.1", @@ -154,10 +145,15 @@ "https-proxy-agent": "^9.0.0", "i18next": "^25.8.11", "jsdom": "^28.1.0", + "katex": "^0.16.45", + "lru-cache": "^11.2.6", "lucide-react": "^0.563.0", + "monaco-editor": "^0.55.1", "mpg123-decoder": "^1.0.3", + "ms": "^2.1.3", "openclaw": "2026.5.12", "opusscript": "^0.1.1", + "pdfjs-dist": "^5.7.284", "playwright-core": "1.59.1", "png2icons": "^2.0.1", "postcss": "^8.5.6", @@ -167,13 +163,17 @@ "react-i18next": "^16.5.4", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", + "rehype-katex": "^7.0.1", + "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "sharp": "^0.34.5", "silk-wasm": "^3.7.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", + "tar": "^6.2.1", "typescript": "^5.9.3", "undici": "8.1.0", "use-stick-to-bottom": "^1.1.3", @@ -181,6 +181,7 @@ "vite-plugin-electron": "^0.29.0", "vite-plugin-electron-renderer": "^0.14.6", "vitest": "^4.0.18", + "xlsx": "^0.18.5", "zustand": "^5.0.11", "zx": "^8.8.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 992f8e8..7747e34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,69 +11,24 @@ importers: .: dependencies: - '@monaco-editor/react': - specifier: ^4.7.0 - version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@sinclair/typebox': - specifier: ^0.34.48 - version: 0.34.48 - '@types/diff': - specifier: ^8.0.0 - version: 8.0.0 - chokidar: - specifier: ^5.0.0 - version: 5.0.0 clawhub: specifier: ^0.5.0 version: 0.5.0 - diff: - specifier: ^9.0.0 - version: 9.0.0 electron-store: specifier: ^11.0.2 version: 11.0.2 electron-updater: specifier: ^6.8.3 version: 6.8.3 - katex: - specifier: ^0.16.45 - version: 0.16.45 - lru-cache: - specifier: ^11.2.6 - version: 11.2.7 - monaco-editor: - specifier: ^0.55.1 - version: 0.55.1 - ms: - specifier: ^2.1.3 - version: 2.1.3 node-machine-id: specifier: ^1.1.12 version: 1.1.12 - pdfjs-dist: - specifier: ^5.7.284 - version: 5.7.284 posthog-node: specifier: ^5.28.0 version: 5.28.5 - rehype-katex: - specifier: ^7.0.1 - version: 7.0.1 - remark-frontmatter: - specifier: ^5.0.0 - version: 5.0.0 - remark-math: - specifier: ^6.0.0 - version: 6.0.0 - tar: - specifier: ^6.2.1 - version: 6.2.1 ws: specifier: ^8.19.0 version: 8.20.0 - xlsx: - specifier: ^0.18.5 - version: 0.18.5 devDependencies: '@buape/carbon': specifier: 0.16.0 @@ -99,6 +54,12 @@ importers: '@larksuiteoapi/node-sdk': specifier: ^1.61.1 version: 1.62.0 + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@openclaw/codex': + specifier: 2026.5.12 + version: 2026.5.12(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(openclaw@2026.5.12(encoding@0.1.13)) '@openclaw/discord': specifier: 2026.5.12 version: 2026.5.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)(openclaw@2026.5.12(encoding@0.1.13)) @@ -147,6 +108,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@sinclair/typebox': + specifier: ^0.34.48 + version: 0.34.48 '@soimy/dingtalk': specifier: ^3.6.2 version: 3.6.2(openclaw@2026.5.12(encoding@0.1.13)) @@ -162,6 +126,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/diff': + specifier: ^8.0.0 + version: 8.0.0 '@types/node': specifier: ^25.3.0 version: 25.5.0 @@ -195,12 +162,18 @@ importers: autoprefixer: specifier: ^10.4.24 version: 10.4.27(postcss@8.5.8) + chokidar: + specifier: ^5.0.0 + version: 5.0.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 clsx: specifier: ^2.1.1 version: 2.1.1 + diff: + specifier: ^9.0.0 + version: 9.0.0 discord-api-types: specifier: ^0.38.47 version: 0.38.47 @@ -237,18 +210,33 @@ importers: jsdom: specifier: ^28.1.0 version: 28.1.0(@noble/hashes@2.0.1) + katex: + specifier: ^0.16.45 + version: 0.16.45 + lru-cache: + specifier: ^11.2.6 + version: 11.2.7 lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 mpg123-decoder: specifier: ^1.0.3 version: 1.0.3 + ms: + specifier: ^2.1.3 + version: 2.1.3 openclaw: specifier: 2026.5.12 version: 2026.5.12(encoding@0.1.13) opusscript: specifier: ^0.1.1 version: 0.1.1 + pdfjs-dist: + specifier: ^5.7.284 + version: 5.7.284 playwright-core: specifier: 1.59.1 version: 1.59.1 @@ -276,9 +264,18 @@ importers: react-router-dom: specifier: ^7.13.0 version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 + remark-frontmatter: + specifier: ^5.0.0 + version: 5.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -297,6 +294,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)) + tar: + specifier: ^6.2.1 + version: 6.2.1 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -318,6 +318,9 @@ importers: vitest: specifier: ^4.0.18 version: 4.1.1(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0)) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 zustand: specifier: ^5.0.11 version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -1674,6 +1677,55 @@ packages: resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} engines: {node: ^18.17.0 || >=20.5.0} + '@openai/codex@0.130.0': + resolution: {integrity: sha512-WGDj+RZ3TXWC/7MlwprgLWOqzpwatPIINPhP3IRzHA0ni+o3QZ4i4xrS2uWwGmHUJ395J5JHwoZAAZYyfJyz6w==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.130.0-darwin-arm64': + resolution: {integrity: sha512-R9pkGC7kwC8yQ8el5hvBlmugQlcsG/pHMEFgZluu03X9fD2TezGxdq3KqRDRCZuMYl07ILamVEoqknuJ0cq7MA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.130.0-darwin-x64': + resolution: {integrity: sha512-gJ+7J8djevgtdra+NgDAiQQPW+O3KTsgGfE3E5dpDfww3zS5OCeV0V2dhxqnJdlOjOSDw99o0P2LqBv19mhpRw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.130.0-linux-arm64': + resolution: {integrity: sha512-tFtH0V9/hEI3d9y7zP92BXI9FM4Z3+STNQaOR52Czv18TRtCFUp7CbIUYaToopuq6UBfnE1VKr8RLhwT5FcbmA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.130.0-linux-x64': + resolution: {integrity: sha512-3VcNlez99xdnEf+kB1IOpWv9fICYV9PiGj4sLCO4TCcShLnyxe+YBGa3poknkvXLnMG0qiN9SMnYS2FGrMxQcA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.130.0-win32-arm64': + resolution: {integrity: sha512-vdpmiNp57L/arZabltLXn8TyEtNa7W1meOEkr+3R6W/8ZyBt++wuqz1Orv134OT2grrcFJsIVCAIPiqUxCvBkA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.130.0-win32-x64': + resolution: {integrity: sha512-FzMznm7fr5/nbjZgOujZ9Y9AbdGm7ji1FOoWiY3U+srqauvZaTgn6o6aCheSL7kuymu7nTLOO/cAyWV6NuesqQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@openclaw/codex@2026.5.12': + resolution: {integrity: sha512-T2kaIg4+vS8AE0Jr7HgLzHSdhlUvCg0VbNqBto3g7frATj05gG/nX6gx2UEj4Y5f8DEKXHrLKwc+vyywVQemgw==} + peerDependencies: + openclaw: '>=2026.5.12' + peerDependenciesMeta: + openclaw: + optional: true + '@openclaw/discord@2026.5.12': resolution: {integrity: sha512-nyB2wDpSyHcXbDwWnGQ+KjnPXcbAOj8MiLe8YS6IJbZUjcqjtObuUn5vh72Srcixupugu1PzNZT0WntO8J4n0A==} peerDependencies: @@ -8713,6 +8765,49 @@ snapshots: dependencies: semver: 7.7.4 + '@openai/codex@0.130.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.130.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.130.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.130.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.130.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.130.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.130.0-win32-x64' + + '@openai/codex@0.130.0-darwin-arm64': + optional: true + + '@openai/codex@0.130.0-darwin-x64': + optional: true + + '@openai/codex@0.130.0-linux-arm64': + optional: true + + '@openai/codex@0.130.0-linux-x64': + optional: true + + '@openai/codex@0.130.0-win32-arm64': + optional: true + + '@openai/codex@0.130.0-win32-x64': + optional: true + + '@openclaw/codex@2026.5.12(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(openclaw@2026.5.12(encoding@0.1.13))': + dependencies: + '@earendil-works/pi-coding-agent': 0.74.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + '@openai/codex': 0.130.0 + ajv: 8.20.0 + ws: 8.20.0 + zod: 4.4.3 + optionalDependencies: + openclaw: 2026.5.12(encoding@0.1.13) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + '@openclaw/discord@2026.5.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)(openclaw@2026.5.12(encoding@0.1.13))': dependencies: '@discordjs/voice': 0.19.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)(opusscript@0.1.1) diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 6373d58..ffbdea8 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -155,6 +155,7 @@ const PLATFORM_NATIVE_SCOPES = { '@reflink': /^reflink-(darwin|linux|win32)-(x64|arm64|x64-gnu|x64-musl|arm64-gnu|arm64-musl|x64-msvc|arm64-msvc)/, '@node-llama-cpp': /^(mac|linux|win)-(arm64|x64|armv7l)(-metal|-cuda|-cuda-ext|-vulkan)?$/, '@esbuild': /^(darwin|linux|win32|android|freebsd|netbsd|openbsd|sunos|aix|openharmony)-(x64|arm64|arm|ia32|loong64|mips64el|ppc64|riscv64|s390x)/, + '@openai': /^codex-(darwin|linux|win32)-(x64|arm64)$/, }; // Unscoped packages that follow a -- convention. @@ -173,6 +174,13 @@ function baseArch(rawArch) { return dash > 0 ? rawArch.slice(0, dash) : rawArch; } +function matchesTargetArch(pkgArch, targetArch) { + if (targetArch === 'universal') { + return pkgArch === 'x64' || pkgArch === 'arm64' || pkgArch === 'universal'; + } + return pkgArch === targetArch || pkgArch === 'universal'; +} + function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) { let removed = 0; @@ -190,7 +198,7 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) { const isMatch = pkgPlatform === platform && - (pkgArch === arch || pkgArch === 'universal'); + matchesTargetArch(pkgArch, arch); if (!isMatch) { try { @@ -215,7 +223,7 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) { const isMatch = pkgPlatform === platform && - (pkgArch === arch || pkgArch === 'universal'); + matchesTargetArch(pkgArch, arch); if (!isMatch) { try { @@ -229,6 +237,75 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) { return removed; } +function cleanupNodeModulesRuntimeJunk(nodeModulesDir, platform, arch) { + let removed = 0; + + const nodeWavDir = join(nodeModulesDir, 'node-wav'); + for (const name of ['x.json', 'x.js', 'x.js~', 'file.wav']) { + try { + const target = join(nodeWavDir, name); + if (existsSync(target)) { + rmSync(target, { recursive: true, force: true }); + removed++; + } + } catch { /* */ } + } + + const treeSitterBashDir = join(nodeModulesDir, 'tree-sitter-bash'); + const treeSitterSrc = join(treeSitterBashDir, 'src'); + for (const name of ['parser.c', 'scanner.c', 'grammar.json', 'tree_sitter']) { + try { + const target = join(treeSitterSrc, name); + if (existsSync(target)) { + rmSync(target, { recursive: true, force: true }); + removed++; + } + } catch { /* */ } + } + + const prebuildsDir = join(treeSitterBashDir, 'prebuilds'); + if (existsSync(prebuildsDir)) { + for (const entry of readdirSync(prebuildsDir)) { + const [entryPlatform, ...entryArchParts] = entry.split('-'); + const entryArch = baseArch(entryArchParts.join('-')); + if (entryPlatform === platform && matchesTargetArch(entryArch, arch)) continue; + try { + rmSync(join(prebuildsDir, entry), { recursive: true, force: true }); + removed++; + } catch { /* */ } + } + } + + return removed; +} + +function cleanupKnownRuntimeJunk(rootDir, platform, arch) { + let removed = 0; + const stack = [rootDir]; + + while (stack.length > 0) { + const dir = stack.pop(); + let entries; + try { entries = readdirSync(normWin(dir), { withFileTypes: true }); } catch { continue; } + + if (basename(dir) === 'node_modules') { + removed += cleanupNodeModulesRuntimeJunk(dir, platform, arch); + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + stack.push(join(dir, entry.name)); + } + } + + return removed; +} + +exports.__test = { + cleanupNativePlatformPackages, + cleanupNodeModulesRuntimeJunk, +}; + // ── Broken module patcher ───────────────────────────────────────────────────── // Some bundled packages have transpiled CJS that sets `module.exports = exports.default` // without ever assigning `exports.default`, leaving module.exports === undefined. @@ -621,6 +698,10 @@ exports.default = async function afterPack(context) { const pluginNM = join(pluginDestDir, 'node_modules'); cleanupUnnecessaryFiles(pluginDestDir); if (existsSync(pluginNM)) { + const pluginJunkRemoved = cleanupKnownRuntimeJunk(pluginDestDir, platform, arch); + if (pluginJunkRemoved > 0) { + console.log(`[after-pack] ✅ ${pluginId}: removed ${pluginJunkRemoved} known runtime junk files/directories.`); + } cleanupKoffi(pluginNM, platform, arch); cleanupNativePlatformPackages(pluginNM, platform, arch); } @@ -761,7 +842,8 @@ exports.default = async function afterPack(context) { // 2. General cleanup on the full openclaw directory (not just node_modules) console.log('[after-pack] 🧹 Cleaning up unnecessary files ...'); const removedRoot = cleanupUnnecessaryFiles(openclawRoot); - console.log(`[after-pack] ✅ Removed ${removedRoot} unnecessary files/directories.`); + const removedKnownJunk = cleanupKnownRuntimeJunk(openclawRoot, platform, arch); + console.log(`[after-pack] ✅ Removed ${removedRoot + removedKnownJunk} unnecessary files/directories.`); // 3. Platform-specific: strip koffi non-target platform binaries const koffiRemoved = cleanupKoffi(dest, platform, arch); diff --git a/scripts/bundle-openclaw.mjs b/scripts/bundle-openclaw.mjs index db25a67..98c19ba 100644 --- a/scripts/bundle-openclaw.mjs +++ b/scripts/bundle-openclaw.mjs @@ -177,6 +177,10 @@ echo` Virtual store root: ${openclawVirtualNM}`; queue.push({ nodeModulesDir: openclawVirtualNM, skipPkg: 'openclaw' }); const SKIP_PACKAGES = new Set([ + // Extra bundled extensions such as @openclaw/codex can declare openclaw as a + // peer/optional dependency. The bundle already copies openclaw to OUTPUT root, + // so do not also copy a duplicate into OUTPUT/node_modules/openclaw. + 'openclaw', 'typescript', '@playwright/test', // @discordjs/opus is a native .node addon compiled for the system Node.js @@ -447,6 +451,25 @@ function patchBundledExtensionPackageJsons(extensionsRoot) { patchBundledExtensionPackageJsons(extensionsDir); +function bundleExternalExtension(pkgName, extensionId) { + const pkgLink = path.join(NODE_MODULES, ...pkgName.split('/')); + if (!fs.existsSync(pkgLink)) { + throw new Error(`Missing extension package "${pkgName}". Run pnpm install first.`); + } + + const realPath = fs.realpathSync(pkgLink); + const dest = path.join(extensionsDir, extensionId); + fs.rmSync(normWin(dest), { recursive: true, force: true }); + fs.mkdirSync(normWin(path.dirname(dest)), { recursive: true }); + fs.cpSync(normWin(realPath), normWin(dest), { + recursive: true, + dereference: true, + }); + echo` Bundled external extension ${pkgName} -> dist/extensions/${extensionId}`; +} + +bundleExternalExtension('@openclaw/codex', 'codex'); + // 6. Clean up the bundle to reduce package size // // This removes platform-agnostic waste: dev artifacts, docs, source maps, @@ -482,6 +505,47 @@ function rmSafe(target) { } catch { return false; } } +function cleanupNodeModulesRuntimeJunk(nodeModulesDir) { + let removedCount = 0; + + const nodeWavDir = path.join(nodeModulesDir, 'node-wav'); + for (const name of ['x.json', 'x.js', 'x.js~', 'file.wav']) { + if (rmSafe(path.join(nodeWavDir, name))) removedCount++; + } + + // tree-sitter-bash ships C sources for rebuilding its native addon. Packaged + // builds use the prebuilt addon/wasm; keep node-types.json because the CJS + // entry exposes it as optional runtime metadata. + const treeSitterSrc = path.join(nodeModulesDir, 'tree-sitter-bash', 'src'); + for (const name of ['parser.c', 'scanner.c', 'grammar.json', 'tree_sitter']) { + if (rmSafe(path.join(treeSitterSrc, name))) removedCount++; + } + + return removedCount; +} + +function cleanupKnownRuntimeJunk(rootDir) { + let removedCount = 0; + const stack = [rootDir]; + + while (stack.length > 0) { + const dir = stack.pop(); + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } + + if (path.basename(dir) === 'node_modules') { + removedCount += cleanupNodeModulesRuntimeJunk(dir); + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + stack.push(path.join(dir, entry.name)); + } + } + + return removedCount; +} + function cleanupBundle(outputDir) { let removedCount = 0; const nm = path.join(outputDir, 'node_modules'); @@ -620,6 +684,8 @@ function cleanupBundle(outputDir) { if (rmSafe(path.join(outputDir, rel))) removedCount++; } + removedCount += cleanupKnownRuntimeJunk(outputDir); + return removedCount; } diff --git a/scripts/openclaw-bundle-config.mjs b/scripts/openclaw-bundle-config.mjs index 98a0018..c20ba40 100644 --- a/scripts/openclaw-bundle-config.mjs +++ b/scripts/openclaw-bundle-config.mjs @@ -28,6 +28,11 @@ export const EXTRA_BUNDLED_PACKAGES = [ // transitive dependency graph from the app bundle context. 'playwright-core', + // The Codex agent harness is published as an OpenClaw extension package. + // Bundle it with the runtime so openai-codex models can register the codex + // harness in packaged builds. + '@openclaw/codex', + // Electron main process QR login flows resolve these files from the // bundled OpenClaw runtime context in packaged builds. 'qrcode-terminal', diff --git a/src/stores/chat.ts b/src/stores/chat.ts index a3ccd0d..93fe95f 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -81,6 +81,15 @@ const CHAT_EVENT_DEDUPE_TTL_MS = 30_000; const HISTORY_PAGE_SIZE = 200; const HISTORY_MAX_RENDERED_MESSAGES = 1_000; const _chatEventDedupe = new Map(); +const OPTIMISTIC_USER_MESSAGE_TTL_MS = 30 * 60 * 1000; + +type PendingOptimisticUserMessage = { + message: RawMessage; + timestampMs: number; + createdAtMs: number; +}; + +const _pendingOptimisticUserMessages = new Map(); type SessionLabelSummary = { sessionKey: string; @@ -529,6 +538,62 @@ function matchesOptimisticUserMessage( return false; } +function rememberPendingOptimisticUserMessage(sessionKey: string, message: RawMessage, timestampMs: number): void { + const now = Date.now(); + const existing = (_pendingOptimisticUserMessages.get(sessionKey) || []) + .filter((entry) => now - entry.createdAtMs <= OPTIMISTIC_USER_MESSAGE_TTL_MS); + existing.push({ message, timestampMs, createdAtMs: now }); + _pendingOptimisticUserMessages.set(sessionKey, existing); +} + +function clearPendingOptimisticUserMessages(sessionKey: string): void { + _pendingOptimisticUserMessages.delete(sessionKey); +} + +function mergePendingOptimisticUserMessages(sessionKey: string, loadedMessages: RawMessage[]): RawMessage[] { + const pending = _pendingOptimisticUserMessages.get(sessionKey); + if (!pending || pending.length === 0) return loadedMessages; + + const now = Date.now(); + let merged = loadedMessages; + const stillPending: PendingOptimisticUserMessage[] = []; + + for (const entry of pending) { + if (now - entry.createdAtMs > OPTIMISTIC_USER_MESSAGE_TTL_MS) { + continue; + } + + const hasServerEcho = loadedMessages.some((message) => + matchesOptimisticUserMessage(message, entry.message, entry.timestampMs), + ); + if (hasServerEcho) { + continue; + } + + const alreadyRendered = merged.some((message) => + message.id === entry.message.id || matchesOptimisticUserMessage(message, entry.message, entry.timestampMs), + ); + if (!alreadyRendered) { + const insertAt = merged.findIndex((message) => + typeof message.timestamp === 'number' && toMs(message.timestamp) > entry.timestampMs, + ); + merged = insertAt === -1 + ? [...merged, entry.message] + : [...merged.slice(0, insertAt), entry.message, ...merged.slice(insertAt)]; + } + + stillPending.push(entry); + } + + if (stillPending.length > 0) { + _pendingOptimisticUserMessages.set(sessionKey, stillPending); + } else { + _pendingOptimisticUserMessages.delete(sessionKey); + } + + return merged; +} + function snapshotStreamingAssistantMessage( currentStream: RawMessage | null, existingMessages: RawMessage[], @@ -1900,6 +1965,7 @@ export const useChatStore = create((set, get) => ({ deleteSession: async (key: string) => { clearCachedSessionHistory(key); clearSessionLabelHydrationTracking(key); + clearPendingOptimisticUserMessages(key); // Hard-delete the session's JSONL transcript on disk. // The main process unlinks .jsonl plus any leftover // .deleted.jsonl and .jsonl.reset.* siblings, then removes the @@ -2141,19 +2207,19 @@ export const useChatStore = create((set, get) => ({ // Restore file attachments for user/assistant messages (from cache + text patterns) const enrichedMessages = enrichWithCachedImages(filteredMessages); - // Preserve the optimistic user message during an active send. - // The Gateway may not include the user's message in chat.history - // until the run completes, causing it to flash out of the UI. - let finalMessages = enrichedMessages; + // Preserve optimistic user messages independently from sending state. + // Gateway phase=end can clear sending before chat.history has persisted + // the user turn; without this, an early quiet reload briefly removes it. + let finalMessages = mergePendingOptimisticUserMessages(currentSessionKey, enrichedMessages); const userMsgAt = get().lastUserMessageAt; if (get().sending && userMsgAt) { const userMsMs = toMs(userMsgAt); const optimistic = getLatestOptimisticUserMessage(get().messages, userMsMs); const hasMatchingUser = optimistic - ? enrichedMessages.some((message) => matchesOptimisticUserMessage(message, optimistic, userMsMs)) + ? finalMessages.some((message) => matchesOptimisticUserMessage(message, optimistic, userMsMs)) : false; if (optimistic && !hasMatchingUser) { - finalMessages = [...enrichedMessages, optimistic]; + finalMessages = [...finalMessages, optimistic]; } } @@ -2487,6 +2553,7 @@ export const useChatStore = create((set, get) => ({ filePath: a.stagedPath, })), }; + rememberPendingOptimisticUserMessage(currentSessionKey, userMsg, nowMs); set((s) => ({ messages: [...s.messages, userMsg], sending: true, diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts index 45428ee..e78f108 100644 --- a/src/stores/chat/helpers.ts +++ b/src/stores/chat/helpers.ts @@ -28,6 +28,15 @@ let _errorRecoveryTimer: ReturnType | null = null; // the sending state after abortRun clears it. let _lastAbortedRunId: string | null = null; const _blockedRunEvents = new Map[]>(); +const OPTIMISTIC_USER_MESSAGE_TTL_MS = 30 * 60 * 1000; + +type PendingOptimisticUserMessage = { + message: RawMessage; + timestampMs: number; + createdAtMs: number; +}; + +const _pendingOptimisticUserMessages = new Map(); function clearErrorRecoveryTimer(): void { if (_errorRecoveryTimer) { @@ -198,6 +207,62 @@ function matchesOptimisticUserMessage( return false; } +function rememberPendingOptimisticUserMessage(sessionKey: string, message: RawMessage, timestampMs: number): void { + const now = Date.now(); + const existing = (_pendingOptimisticUserMessages.get(sessionKey) || []) + .filter((entry) => now - entry.createdAtMs <= OPTIMISTIC_USER_MESSAGE_TTL_MS); + existing.push({ message, timestampMs, createdAtMs: now }); + _pendingOptimisticUserMessages.set(sessionKey, existing); +} + +function clearPendingOptimisticUserMessages(sessionKey: string): void { + _pendingOptimisticUserMessages.delete(sessionKey); +} + +function mergePendingOptimisticUserMessages(sessionKey: string, loadedMessages: RawMessage[]): RawMessage[] { + const pending = _pendingOptimisticUserMessages.get(sessionKey); + if (!pending || pending.length === 0) return loadedMessages; + + const now = Date.now(); + let merged = loadedMessages; + const stillPending: PendingOptimisticUserMessage[] = []; + + for (const entry of pending) { + if (now - entry.createdAtMs > OPTIMISTIC_USER_MESSAGE_TTL_MS) { + continue; + } + + const hasServerEcho = loadedMessages.some((message) => + matchesOptimisticUserMessage(message, entry.message, entry.timestampMs), + ); + if (hasServerEcho) { + continue; + } + + const alreadyRendered = merged.some((message) => + message.id === entry.message.id || matchesOptimisticUserMessage(message, entry.message, entry.timestampMs), + ); + if (!alreadyRendered) { + const insertAt = merged.findIndex((message) => + typeof message.timestamp === 'number' && toMs(message.timestamp) > entry.timestampMs, + ); + merged = insertAt === -1 + ? [...merged, entry.message] + : [...merged.slice(0, insertAt), entry.message, ...merged.slice(insertAt)]; + } + + stillPending.push(entry); + } + + if (stillPending.length > 0) { + _pendingOptimisticUserMessages.set(sessionKey, stillPending); + } else { + _pendingOptimisticUserMessages.delete(sessionKey); + } + + return merged; +} + function snapshotStreamingAssistantMessage( currentStream: RawMessage | null, existingMessages: RawMessage[], @@ -1235,6 +1300,9 @@ export { isToolOnlyMessage, normalizeStreamingMessage, matchesOptimisticUserMessage, + rememberPendingOptimisticUserMessage, + clearPendingOptimisticUserMessages, + mergePendingOptimisticUserMessages, snapshotStreamingAssistantMessage, getLatestOptimisticUserMessage, setHistoryPollTimer, diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts index f627067..e0a267b 100644 --- a/src/stores/chat/history-actions.ts +++ b/src/stores/chat/history-actions.ts @@ -13,6 +13,7 @@ import { isToolResultRole, loadMissingPreviews, matchesOptimisticUserMessage, + mergePendingOptimisticUserMessages, toMs, } from './helpers'; import { buildCronSessionHistoryPath, isCronSessionKey } from './cron-session-utils'; @@ -106,19 +107,19 @@ export function createHistoryActions( // Restore file attachments for user/assistant messages (from cache + text patterns) const enrichedMessages = enrichWithCachedImages(filteredMessages); - // Preserve the optimistic user message during an active send. - // The Gateway may not include the user's message in chat.history - // until the run completes, causing it to flash out of the UI. - let finalMessages = enrichedMessages; + // Preserve optimistic user messages independently from sending state. + // Gateway phase=end can clear sending before chat.history has persisted + // the user turn; without this, an early quiet reload briefly removes it. + let finalMessages = mergePendingOptimisticUserMessages(currentSessionKey, enrichedMessages); const userMsgAt = get().lastUserMessageAt; if (get().sending && userMsgAt) { const userMsMs = toMs(userMsgAt); const optimistic = getLatestOptimisticUserMessage(get().messages, userMsMs); const hasMatchingUser = optimistic - ? enrichedMessages.some((message) => matchesOptimisticUserMessage(message, optimistic, userMsMs)) + ? finalMessages.some((message) => matchesOptimisticUserMessage(message, optimistic, userMsMs)) : false; if (optimistic && !hasMatchingUser) { - finalMessages = [...enrichedMessages, optimistic]; + finalMessages = [...finalMessages, optimistic]; } } diff --git a/src/stores/chat/runtime-send-actions.ts b/src/stores/chat/runtime-send-actions.ts index ddb2da6..a7946ae 100644 --- a/src/stores/chat/runtime-send-actions.ts +++ b/src/stores/chat/runtime-send-actions.ts @@ -8,6 +8,7 @@ import { setHistoryPollTimer, setLastChatEventAt, setLastAbortedRunId, + rememberPendingOptimisticUserMessage, takeBlockedRunEvents, upsertImageCacheEntry, } from './helpers'; @@ -103,6 +104,7 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick ({ messages: [...s.messages, userMsg], sending: true, diff --git a/src/stores/chat/session-actions.ts b/src/stores/chat/session-actions.ts index 32ad630..1b9d541 100644 --- a/src/stores/chat/session-actions.ts +++ b/src/stores/chat/session-actions.ts @@ -1,5 +1,5 @@ import { invokeIpc } from '@/lib/api-client'; -import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers'; +import { clearPendingOptimisticUserMessages, getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers'; import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types'; import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; @@ -265,6 +265,7 @@ export function createSessionActions( deleteSession: async (key: string) => { clearSessionLabelHydrationTracking(key); + clearPendingOptimisticUserMessages(key); // Hard-delete the session's JSONL transcript on disk. // The main process unlinks .jsonl plus any leftover // .deleted.jsonl and .jsonl.reset.* siblings, then removes the diff --git a/tests/unit/after-pack-cleanup.test.ts b/tests/unit/after-pack-cleanup.test.ts new file mode 100644 index 0000000..3744159 --- /dev/null +++ b/tests/unit/after-pack-cleanup.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment node +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { afterEach, describe, expect, it } from 'vitest'; + +const require = createRequire(import.meta.url); + +type AfterPackTestHooks = { + cleanupNativePlatformPackages: (nodeModulesDir: string, platform: string, arch: string) => number; + cleanupNodeModulesRuntimeJunk: (nodeModulesDir: string, platform: string, arch: string) => number; +}; + +const afterPack = require('../../scripts/after-pack.cjs') as { __test?: AfterPackTestHooks }; + +describe('after-pack cleanup helpers', () => { + const tempRoots: string[] = []; + + afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + function makeTempNodeModules(): string { + const root = mkdtempSync(join(tmpdir(), 'clawx-after-pack-')); + tempRoots.push(root); + const nodeModules = join(root, 'node_modules'); + mkdirSync(nodeModules, { recursive: true }); + return nodeModules; + } + + function makePackage(dir: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'package.json'), '{"version":"1.0.0"}\n', 'utf8'); + } + + it('keeps both mac Codex native packages for universal builds', () => { + const nodeModules = makeTempNodeModules(); + makePackage(join(nodeModules, '@openai', 'codex-darwin-arm64')); + makePackage(join(nodeModules, '@openai', 'codex-darwin-x64')); + makePackage(join(nodeModules, '@openai', 'codex-linux-x64')); + + afterPack.__test!.cleanupNativePlatformPackages(nodeModules, 'darwin', 'universal'); + + expect(existsSync(join(nodeModules, '@openai', 'codex-darwin-arm64'))).toBe(true); + expect(existsSync(join(nodeModules, '@openai', 'codex-darwin-x64'))).toBe(true); + expect(existsSync(join(nodeModules, '@openai', 'codex-linux-x64'))).toBe(false); + }); + + it('keeps both mac tree-sitter-bash prebuilds for universal builds', () => { + const nodeModules = makeTempNodeModules(); + const prebuilds = join(nodeModules, 'tree-sitter-bash', 'prebuilds'); + mkdirSync(join(prebuilds, 'darwin-arm64'), { recursive: true }); + mkdirSync(join(prebuilds, 'darwin-x64'), { recursive: true }); + mkdirSync(join(prebuilds, 'linux-x64'), { recursive: true }); + + afterPack.__test!.cleanupNodeModulesRuntimeJunk(nodeModules, 'darwin', 'universal'); + + expect(existsSync(join(prebuilds, 'darwin-arm64'))).toBe(true); + expect(existsSync(join(prebuilds, 'darwin-x64'))).toBe(true); + expect(existsSync(join(prebuilds, 'linux-x64'))).toBe(false); + }); +}); diff --git a/tests/unit/channel-config.test.ts b/tests/unit/channel-config.test.ts index cdcb60e..2d3bf3b 100644 --- a/tests/unit/channel-config.test.ts +++ b/tests/unit/channel-config.test.ts @@ -159,7 +159,7 @@ describe('WeCom plugin configuration', () => { expect(plugins.entries['wecom'].enabled).toBe(true); }); - it('normalizes feishu plugin registration to openclaw-lark and disables built-in feishu on save', async () => { + it('normalizes feishu plugin registration to openclaw-lark and removes built-in feishu on save', async () => { const { saveChannelConfig, writeOpenClawConfig } = await import('@electron/utils/channel-config'); await writeOpenClawConfig({ @@ -184,7 +184,7 @@ describe('WeCom plugin configuration', () => { expect(plugins.allow).not.toContain('feishu'); expect(plugins.allow).not.toContain('feishu-openclaw-plugin'); expect(plugins.entries['openclaw-lark']).toEqual({ enabled: true }); - expect(plugins.entries.feishu).toEqual({ enabled: false }); + expect(plugins.entries.feishu).toBeUndefined(); expect(plugins.entries['feishu-openclaw-plugin']).toBeUndefined(); }); diff --git a/tests/unit/chat-history-actions.test.ts b/tests/unit/chat-history-actions.test.ts index 21e8537..72d6fe8 100644 --- a/tests/unit/chat-history-actions.test.ts +++ b/tests/unit/chat-history-actions.test.ts @@ -59,6 +59,7 @@ vi.mock('@/stores/chat/helpers', () => ({ isInternalMessage: (...args: unknown[]) => isInternalMessage(...args), isToolResultRole: (...args: unknown[]) => isToolResultRole(...args), loadMissingPreviews: (...args: unknown[]) => loadMissingPreviews(...args), + mergePendingOptimisticUserMessages: (_sessionKey: string, messages: unknown[]) => messages, matchesOptimisticUserMessage: ( candidate: { role: string; timestamp?: number; content?: unknown; _attachedFiles?: Array<{ filePath?: string; fileName?: string; mimeType?: string; fileSize?: number }> }, optimistic: { role: string; timestamp?: number; content?: unknown; _attachedFiles?: Array<{ filePath?: string; fileName?: string; mimeType?: string; fileSize?: number }> }, diff --git a/tests/unit/chat-store-history-retry.test.ts b/tests/unit/chat-store-history-retry.test.ts index 0d770bf..b67b288 100644 --- a/tests/unit/chat-store-history-retry.test.ts +++ b/tests/unit/chat-store-history-retry.test.ts @@ -483,4 +483,133 @@ describe('useChatStore startup history retry', () => { expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); + + it('keeps the optimistic user message when completion refresh wins the transcript write race', async () => { + const { useChatStore } = await import('@/stores/chat'); + let historyMessages: Array> = []; + let resolveSend: ((value: { runId: string }) => void) | null = null; + + gatewayRpcMock.mockImplementation((method: string) => { + if (method === 'chat.send') { + return new Promise((resolve) => { + resolveSend = resolve as (value: { runId: string }) => void; + }); + } + if (method === 'chat.history') { + return Promise.resolve({ messages: historyMessages }); + } + return Promise.resolve({}); + }); + + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:main' }], + messages: [], + sessionLabels: {}, + sessionLastActivity: {}, + sending: false, + activeRunId: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + const sendPromise = useChatStore.getState().sendMessage('hello from app'); + expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['hello from app']); + + // Simulate Gateway phase=end clearing send state before chat.history has + // persisted the user turn. + useChatStore.setState({ + sending: false, + activeRunId: null, + pendingFinal: false, + lastUserMessageAt: null, + }); + + await useChatStore.getState().loadHistory(true); + expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['hello from app']); + + historyMessages = [{ + role: 'user', + content: 'hello from app', + timestamp: Date.now() / 1000, + id: 'server-user', + }]; + vi.advanceTimersByTime(1_000); + await useChatStore.getState().loadHistory(true); + + expect(useChatStore.getState().messages).toHaveLength(1); + expect(useChatStore.getState().messages[0]).toMatchObject({ + id: 'server-user', + role: 'user', + content: 'hello from app', + }); + + resolveSend?.({ runId: 'run-1' }); + await sendPromise; + }); + + it('does not restore a pending optimistic message after deleting the session', async () => { + const { useChatStore } = await import('@/stores/chat'); + let resolveSend: ((value: { runId: string }) => void) | null = null; + + gatewayRpcMock.mockImplementation((method: string) => { + if (method === 'chat.send') { + return new Promise((resolve) => { + resolveSend = resolve as (value: { runId: string }) => void; + }); + } + if (method === 'chat.history') { + return Promise.resolve({ messages: [] }); + } + return Promise.resolve({}); + }); + hostApiFetchMock.mockImplementation((url: string) => { + if (url === '/api/sessions/delete') { + return Promise.resolve({ success: true }); + } + return Promise.resolve({ messages: [] }); + }); + + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:main' }], + messages: [], + sessionLabels: {}, + sessionLastActivity: {}, + sending: false, + activeRunId: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + const sendPromise = useChatStore.getState().sendMessage('message that will be deleted'); + expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ + 'message that will be deleted', + ]); + + await useChatStore.getState().deleteSession('agent:main:main'); + expect(useChatStore.getState().messages).toEqual([]); + + await useChatStore.getState().loadHistory(true); + expect(useChatStore.getState().messages).toEqual([]); + + resolveSend?.({ runId: 'run-deleted' }); + await sendPromise; + }); }); diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index 8494712..97de60a 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -465,7 +465,7 @@ describe('sanitizeOpenClawConfig', () => { expect(telegram.botToken).toBe('telegram-token'); }); - it('normalizes legacy feishu plugin state to a single external plugin and disables built-in feishu', async () => { + it('normalizes legacy feishu plugin state to a single external plugin and removes built-in feishu', async () => { await writeOpenClawJson({ channels: { feishu: { @@ -507,7 +507,7 @@ describe('sanitizeOpenClawConfig', () => { enabled: true, config: { preserved: true }, }); - expect(entries.feishu).toEqual({ enabled: false }); + expect(entries.feishu).toBeUndefined(); }); it('removes residual feishu plugin registrations when feishu channel is not configured', async () => { @@ -738,6 +738,7 @@ describe('sanitizeOpenClawConfig', () => { { dir: 'groq', id: 'groq', enabledByDefault: true }, { dir: 'alibaba', id: 'alibaba', enabledByDefault: true }, { dir: 'memory-core', id: 'memory-core' }, + { dir: 'codex', id: 'codex', providers: ['codex'] }, { dir: 'openrouter', id: 'openrouter', enabledByDefault: true, providers: ['openrouter'] }, { dir: 'anthropic', id: 'anthropic', enabledByDefault: true, providers: ['anthropic'] }, ]) { @@ -780,6 +781,7 @@ describe('sanitizeOpenClawConfig', () => { expect(allow).toContain('custom-plugin'); expect(allow).toContain('browser'); expect(allow).toContain('memory-core'); + expect(allow).toContain('codex'); expect(allow).toContain('alibaba'); expect(allow).not.toContain('groq'); expect(allow).toContain('openrouter'); diff --git a/tests/unit/openclaw-bundle-config.test.ts b/tests/unit/openclaw-bundle-config.test.ts index 9bcb303..e08b83c 100644 --- a/tests/unit/openclaw-bundle-config.test.ts +++ b/tests/unit/openclaw-bundle-config.test.ts @@ -22,6 +22,7 @@ describe('openclaw bundle config', () => { 'silk-wasm', 'acpx', 'playwright-core', + '@openclaw/codex', 'qrcode-terminal', ])); const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { diff --git a/tests/unit/provider-runtime-sync.test.ts b/tests/unit/provider-runtime-sync.test.ts index 6a967ca..673ce15 100644 --- a/tests/unit/provider-runtime-sync.test.ts +++ b/tests/unit/provider-runtime-sync.test.ts @@ -186,7 +186,7 @@ describe('provider-runtime-sync refresh strategy', () => { expect(gateway.debouncedRestart).not.toHaveBeenCalled(); }); - it('uses gpt-5.4 as the browser OAuth default model for OpenAI', async () => { + it('uses gpt-5.5 as the browser OAuth default model for OpenAI', async () => { mocks.getProvider.mockResolvedValue( createProvider({ id: 'openai-personal', @@ -209,7 +209,7 @@ describe('provider-runtime-sync refresh strategy', () => { expect(mocks.setOpenClawDefaultModel).toHaveBeenCalledWith( 'openai-codex', - 'openai-codex/gpt-5.4', + 'openai-codex/gpt-5.5', expect.any(Array), ); }); diff --git a/tests/unit/sanitize-config.test.ts b/tests/unit/sanitize-config.test.ts index d6ae8cb..507f4a9 100644 --- a/tests/unit/sanitize-config.test.ts +++ b/tests/unit/sanitize-config.test.ts @@ -63,7 +63,7 @@ async function sanitizeConfig( 'qqbot', ]); const BUNDLED_ALLOWLIST_PRESERVE_IDS = new Set( - bundledPlugins?.preserveIds ?? ['browser', 'acpx', 'memory-core'], + bundledPlugins?.preserveIds ?? ['browser', 'acpx', 'memory-core', 'codex'], ); /** Non-throwing async existence check. */ @@ -852,7 +852,7 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => { }); const bundled = { - all: ['browser', 'acpx', 'memory-core', 'openai', 'anthropic', 'diffs'], + all: ['browser', 'acpx', 'memory-core', 'codex', 'openai', 'anthropic', 'diffs'], enabledByDefault: ['browser', 'acpx', 'openai', 'anthropic'], providersByPluginId: { openai: ['openai'], @@ -866,7 +866,7 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => { const result = await readConfig(); const plugins = result.plugins as Record; const allow = plugins.allow as string[]; - expect(allow).toEqual(expect.arrayContaining(['customPlugin', 'browser', 'acpx', 'memory-core'])); + expect(allow).toEqual(expect.arrayContaining(['customPlugin', 'browser', 'acpx', 'memory-core', 'codex'])); expect(allow).not.toContain('openai'); expect(allow).not.toContain('anthropic'); expect(allow).not.toContain('diffs');