From fed9deded39acbae935b13eecc0098daca38831a Mon Sep 17 00:00:00 2001 From: fancyboi999 <135568692+fancyboi999@users.noreply.github.com> Date: Fri, 22 May 2026 12:51:51 +0800 Subject: [PATCH 1/3] fix(subtask): derive subtask state from messages, remove render-time mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3147. `MessageList` reconstructed the subtask card map by calling `updateSubtask()` during render from its per-message render loop. That helper actually mutated the SubtaskContext `tasks` object in place — only the SSE-driven `latestMessage` path called `setState`. Three consequences: 1. React 19 Strict Mode's double render surfaces the pattern as "Cannot update a component while rendering a different component." 2. Lifecycle regressions like #3107 BUG-007 (cards stuck on "running") were masked: when the backend's final ToolMessage arrived, the render-time mutation silently updated the shared object but did not trigger a re-render unless a follow-up `latestMessage` event happened to land. 3. `SubtaskCard` had to `useSubtask(id)!` non-null-assert the value, which only worked because the same render had just mutated it in. Two write paths split by data origin: - **Base fields** (status / result / error / description / ...) are derived from the message list by a pure `buildSubtaskMapFromMessages(messages)`. `MessageList` passes each entry directly to `SubtaskCard` as a `task` prop. No round trip through context, so the first render is already correct — no effect lag, no `!` non-null assertion. - **Latest streaming AIMessage** still comes from the `task_running` custom SSE event handled in `useThreadStream`. That fires outside React render, so writing it into context is safe. The provider in `tasks/context.tsx` now owns only this map; `SubtaskCard` reads it via `useLatestSubtaskMessage(taskId)` and merges into the prop inside a `useMemo`. Old API surface (`useSubtask` / `useUpdateSubtask`) is gone — replaced by `useLatestSubtaskMessage` / `useUpdateLatestMessage`. - `core/tasks/derive.ts` — new pure `buildSubtaskMapFromMessages(messages)`. Handles multi-tool-call AI messages, missing matching tool messages (stays in_progress), and non-task tool_calls (ignored). Forward-compatible with #3146 — when `parseSubtaskResult` grows the second `additional_kwargs` argument the call site will start preferring the structured field automatically. - `core/tasks/context.tsx` — provider trimmed to the latest-message map, exposes `useLatestSubtaskMessage(id)` and `useUpdateLatestMessage()`. - `components/.../message-list.tsx` — `useMemo` the derived map, removes the render-time `updateSubtask` loop, passes each `task` straight into `SubtaskCard`. Drops unused `extractTextFromMessage` import. - `components/.../subtask-card.tsx` — takes `task: Subtask` as a prop, merges `latestMessage` from context via `useLatestSubtaskMessage`. - `core/threads/hooks.ts` — `onCustomEvent` handler renames the import to `useUpdateLatestMessage` and calls it with `(taskId, message)`. - `vitest.config.ts` — include `*.test.tsx`, set `environment: "jsdom"`, `globals: true` so the new React hook / render tests can run. - `package.json` — `jsdom` + `@testing-library/react` + `@testing-library/dom` dev deps for the StrictMode regression test. - `tests/unit/core/tasks/derive-subtask-map.test.ts` — 7 cases covering empty, in_progress seed, completed / failed flips, multi-tool-call, and non-task-tool-ignored. - `tests/unit/core/tasks/context.test.tsx` — 4 cases including a `` render that spies on `console.error` and asserts the "Cannot update a component while rendering" warning is **not** logged. That assertion is the regression for the root cause. `make dev`, sent "use the task tool with general-purpose subagent to run echo derive_v2", watched the card go from shimmering in_progress to completed-with-output, then the AI's final answer rendered cleanly. The previous attempt at this PR kept base state in a context + effect and reproduced `Cannot read properties of undefined (reading 'status')` in `SubtaskCard` on the very first render; the prop-driven version above fixes that. Refs: bytedance/deer-flow#3138 (split summary), #3107 (BUG-007 origin), --- frontend/package.json | 3 + frontend/pnpm-lock.yaml | 419 +++++++++++++++++- .../workspace/messages/message-list.tsx | 54 +-- .../workspace/messages/subtask-card.tsx | 17 +- frontend/src/core/tasks/context.tsx | 102 +++-- frontend/src/core/tasks/derive.ts | 57 +++ frontend/src/core/threads/hooks.ts | 6 +- .../tests/unit/core/tasks/context.test.tsx | 117 +++++ .../core/tasks/derive-subtask-map.test.ts | 155 +++++++ frontend/vitest.config.ts | 4 +- 10 files changed, 865 insertions(+), 69 deletions(-) create mode 100644 frontend/src/core/tasks/derive.ts create mode 100644 frontend/tests/unit/core/tasks/context.test.tsx create mode 100644 frontend/tests/unit/core/tasks/derive-subtask-map.test.ts diff --git a/frontend/package.json b/frontend/package.json index 0a46ee4527..6d5aaa8c3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -95,12 +95,15 @@ "@eslint/eslintrc": "^3.3.1", "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4.0.15", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/gsap": "^3.0.0", "@types/node": "^20.14.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "eslint": "^9.23.0", "eslint-config-next": "^15.2.3", + "jsdom": "^29.1.1", "postcss": "^8.5.3", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 426b607e8d..09b63da4b2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -231,6 +231,12 @@ importers: '@tailwindcss/postcss': specifier: ^4.0.15 version: 4.1.18 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@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.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/gsap': specifier: ^3.0.0 version: 3.0.0 @@ -249,6 +255,9 @@ importers: eslint-config-next: specifier: ^15.2.3 version: 15.5.12(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 postcss: specifier: ^8.5.3 version: 8.5.6 @@ -272,7 +281,7 @@ importers: version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vitest: specifier: ^4.1.4 - version: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) + version: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(jsdom@29.1.1)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) packages: @@ -299,6 +308,25 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -323,6 +351,10 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} @@ -434,6 +466,42 @@ packages: '@codemirror/view@6.39.13': resolution: {integrity: sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4': + resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -637,6 +705,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -2048,6 +2125,25 @@ packages: '@tanstack/virtual-core@3.13.23': resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@theguild/remark-mermaid@0.3.0': resolution: {integrity: sha512-Fy1J4FSj8totuHsHFpaeWyWRaRSIvpzGTRoEfnNJc1JmLV9uV70sYE3zcT+Jj5Yw20Xq4iCsiT+3Ho49BBZcBQ==} peerDependencies: @@ -2074,6 +2170,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2550,6 +2649,10 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -2572,6 +2675,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2668,6 +2774,9 @@ packages: peerDependencies: react: '>=16.8' + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2884,6 +2993,10 @@ packages: css-to-react-native@3.2.0: resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3046,6 +3159,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3085,6 +3202,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -3129,6 +3249,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dompurify@3.3.1: resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} @@ -3172,6 +3295,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + errx@0.1.0: resolution: {integrity: sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==} @@ -3663,6 +3790,10 @@ packages: hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3821,6 +3952,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3908,6 +4042,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -4093,6 +4236,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4173,6 +4320,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -4595,6 +4745,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -4749,6 +4902,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -4788,6 +4945,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -4927,6 +5087,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4998,6 +5162,10 @@ packages: resolution: {integrity: sha512-HanEzgXHlX3fzpGgxPoR3qI7FDpc/B+uE/KplzA6BkZGlWMaH98B/1Amq+OBF1pYPlGNzAXPYNHlrEVBvRBnHQ==} engines: {node: '>=16'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5213,6 +5381,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + system-architecture@0.1.0: resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} engines: {node: '>=18'} @@ -5256,6 +5427,13 @@ packages: resolution: {integrity: sha512-xRnPkJx9nvE5MF6LkB5e8QJjE2FW8269wTu/LQdf7zZqBgPly0QJPf/CWAo7srj5so4yXfoLEdCFgurlpi47zg==} hasBin: true + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5267,6 +5445,14 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5357,6 +5543,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unhead@2.1.4: resolution: {integrity: sha512-+5091sJqtNNmgfQ07zJOgUnMIMKzVKAWjeMlSrTdSGPB6JSozhpjUKuMfWEoLxlMAfhIvgOU8Me0XJvmMA/0fA==} @@ -5650,12 +5840,28 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -5689,6 +5895,13 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -5777,6 +5990,32 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -5794,6 +6033,10 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@cfworker/json-schema@4.1.1': {} '@chevrotain/cst-dts-gen@11.0.3': @@ -6068,6 +6311,30 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -6208,6 +6475,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -7545,6 +7814,27 @@ snapshots: '@tanstack/virtual-core@3.13.23': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/react@19.2.13) + '@theguild/remark-mermaid@0.3.0(react@19.2.4)': dependencies: mermaid: 11.12.2 @@ -7582,6 +7872,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -8145,6 +8437,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -8164,6 +8458,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -8270,6 +8568,10 @@ snapshots: mathjax-full: 3.2.2 react: 19.2.4 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -8491,6 +8793,11 @@ snapshots: css-color-keywords: 1.0.0 postcss-value-parser: 4.2.0 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): @@ -8679,6 +8986,13 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -8711,6 +9025,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -8753,6 +9069,8 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + dompurify@3.3.1: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -8790,6 +9108,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + errx@0.1.0: {} es-abstract@1.24.1: @@ -9584,6 +9904,12 @@ snapshots: hookable@6.1.1: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -9719,6 +10045,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -9803,6 +10131,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.0 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -9957,6 +10311,8 @@ snapshots: dependencies: react: 19.2.4 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -10164,6 +10520,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -10880,6 +11238,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-browserify@1.0.1: {} path-data-parser@0.1.0: {} @@ -10961,6 +11323,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -11000,6 +11368,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@19.2.13)(react@19.2.4): dependencies: '@types/hast': 3.0.4 @@ -11237,6 +11607,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -11365,6 +11737,10 @@ snapshots: postcss-value-parser: 4.2.0 yoga-layout: 3.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} scroll-into-view-if-needed@3.1.0: @@ -11641,6 +12017,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + system-architecture@0.1.0: {} tabbable@6.4.0: {} @@ -11675,6 +12053,12 @@ snapshots: chalk: 5.6.2 clipboardy: 4.0.0 + tldts-core@7.0.30: {} + + tldts@7.0.30: + dependencies: + tldts-core: 7.0.30 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -11688,6 +12072,14 @@ snapshots: totalist@3.0.1: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.30 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -11798,6 +12190,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.25.0: {} + unhead@2.1.4: dependencies: hookable: 6.1.1 @@ -11995,7 +12389,7 @@ snapshots: lightningcss: 1.30.2 yaml: 2.8.3 - vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)): + vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(jsdom@29.1.1)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.4 '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) @@ -12020,6 +12414,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 20.19.33 + jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -12052,10 +12447,26 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -12110,6 +12521,10 @@ snapshots: word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml@2.8.3: {} yocto-queue@0.1.0: {} diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index ca8672a3a6..537142ebb4 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -16,7 +16,6 @@ import { import { extractContentFromMessage, extractPresentFilesFromMessage, - extractTextFromMessage, getAssistantTurnCopyData, getAssistantTurnUsageMessages, getMessageGroups, @@ -28,8 +27,7 @@ import { } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import type { Subtask } from "@/core/tasks"; -import { useUpdateSubtask } from "@/core/tasks/context"; -import { parseSubtaskResult } from "@/core/tasks/subtask-result"; +import { buildSubtaskMapFromMessages } from "@/core/tasks/derive"; import type { AgentThreadState } from "@/core/threads"; import { cn } from "@/lib/utils"; @@ -177,7 +175,6 @@ export function MessageList({ }) { const { t } = useI18n(); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); - const updateSubtask = useUpdateSubtask(); const messages = thread.messages; const groupedMessages = getMessageGroups(messages); const turnUsageMessagesByGroupIndex = @@ -196,6 +193,18 @@ export function MessageList({ [messages, thread.getMessagesMetadata, thread.isLoading], ); + // `derivedSubtasks` is computed once via `useMemo` from the thread + // message list. `SubtaskCard` receives each entry as a `task` prop. + // The previous implementation called `updateSubtask` *during render* + // from the per-message loop below, which mutated the shared context + // object without triggering a re-render and is the React-19 Strict-Mode + // warning pattern #3147 removes. The SSE-driven `latestMessage` path + // still flows through context (see `useUpdateLatestMessage`). + const derivedSubtasks = useMemo( + () => buildSubtaskMapFromMessages(messages), + [messages], + ); + const renderAssistantCopyButton = useCallback( (messages: Message[], isStreaming: boolean) => { const clipboardData = getAssistantTurnCopyData(messages, { isStreaming }); @@ -354,29 +363,19 @@ export function MessageList({ ); } else if (group.type === "assistant:subagent") { + // The subtask context is fed by `derivedSubtasks` / the effect + // above — render only consumes it. Collect the per-group task + // *references* (used downstream to build subtask cards and + // count rendered subtasks) directly from the AI tool_calls so + // we do not mutate any shared state here. const tasks = new Set(); for (const message of group.messages) { - if (message.type === "ai") { - for (const toolCall of message.tool_calls ?? []) { - if (toolCall.name === "task") { - const task: Subtask = { - id: toolCall.id!, - subagent_type: toolCall.args.subagent_type, - description: toolCall.args.description, - prompt: toolCall.args.prompt, - status: "in_progress", - }; - updateSubtask(task); - tasks.add(task); - } - } - } else if (message.type === "tool") { - const taskId = message.tool_call_id; - if (taskId) { - const parsed = parseSubtaskResult( - extractTextFromMessage(message), - ); - updateSubtask({ id: taskId, ...parsed }); + if (message.type !== "ai") continue; + for (const toolCall of message.tool_calls ?? []) { + if (toolCall.name !== "task" || !toolCall.id) continue; + const task = derivedSubtasks[toolCall.id]; + if (task) { + tasks.add(task); } } } @@ -417,10 +416,13 @@ export function MessageList({ ?.filter((toolCall) => toolCall.name === "task") .map((toolCall) => toolCall.id); for (const taskId of taskIds ?? []) { + if (!taskId) continue; + const taskForCard = derivedSubtasks[taskId]; + if (!taskForCard) continue; results.push( , ); diff --git a/frontend/src/components/workspace/messages/subtask-card.tsx b/frontend/src/components/workspace/messages/subtask-card.tsx index b2aa74b345..68c5b4c81a 100644 --- a/frontend/src/components/workspace/messages/subtask-card.tsx +++ b/frontend/src/components/workspace/messages/subtask-card.tsx @@ -20,7 +20,8 @@ import { useI18n } from "@/core/i18n/hooks"; import { hasToolCalls } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { streamdownPluginsWithWordAnimation } from "@/core/streamdown"; -import { useSubtask } from "@/core/tasks/context"; +import { useLatestSubtaskMessage } from "@/core/tasks/context"; +import type { Subtask } from "@/core/tasks/types"; import { explainLastToolCall } from "@/core/tools/utils"; import { cn } from "@/lib/utils"; @@ -31,17 +32,25 @@ import { MarkdownContent } from "./markdown-content"; export function SubtaskCard({ className, - taskId, + task: baseTask, isLoading, }: { className?: string; - taskId: string; + task: Subtask; isLoading: boolean; }) { const { t } = useI18n(); const [collapsed, setCollapsed] = useState(true); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); - const task = useSubtask(taskId)!; + // Bytedance/deer-flow#3147: base fields come in as a prop (derived + // from the message list inside MessageList), only the streaming + // `latestMessage` is pulled from the shared context — it lands via + // `task_running` SSE events outside the render phase. + const latestMessage = useLatestSubtaskMessage(baseTask.id); + const task = useMemo( + () => (latestMessage ? { ...baseTask, latestMessage } : baseTask), + [baseTask, latestMessage], + ); const icon = useMemo(() => { if (task.status === "completed") { return ; diff --git a/frontend/src/core/tasks/context.tsx b/frontend/src/core/tasks/context.tsx index ea85772cbc..9c5813630c 100644 --- a/frontend/src/core/tasks/context.tsx +++ b/frontend/src/core/tasks/context.tsx @@ -1,53 +1,89 @@ -import { createContext, useCallback, useContext, useState } from "react"; +import type { AIMessage } from "@langchain/langgraph-sdk"; +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; -import type { Subtask } from "./types"; - -export interface SubtaskContextValue { - tasks: Record; - setTasks: (tasks: Record) => void; +/** + * Bytedance/deer-flow issue #3147: the `task` subtask state used to live + * entirely on a mutable React context — `MessageList` wrote into it + * during render and `SubtaskCard` read it back through `useSubtask`. + * Render-time mutation broke React 19 Strict Mode and hid card lifecycle + * regressions because only the SSE-driven `latestMessage` path actually + * called `setState`. + * + * The new shape splits responsibilities by data origin: + * + * 1. **Base fields** (`status` / `result` / `error` / `description` / ...) + * are derived from the thread's message list with + * `buildSubtaskMapFromMessages(messages)` and passed to `SubtaskCard` + * directly as a `task` prop. They no longer round-trip through the + * context, so the first render after a new message arrives is already + * correct — no `useEffect` lag, no `!`-asserted undefined. + * + * 2. **Latest streaming AIMessage** comes from the `task_running` custom + * SSE event handled in `useThreadStream`. That handler fires outside + * React render, so writing to context state there is safe. The + * Provider in this file owns that map and `SubtaskCard` reads it via + * `useLatestSubtaskMessage(taskId)`. + */ +interface LatestMessageContextValue { + latestMessages: Record; + setLatestMessage: (taskId: string, message: AIMessage) => void; } -export const SubtaskContext = createContext({ - tasks: {}, - setTasks: () => { - /* noop */ - }, -}); +const LatestMessageContext = createContext< + LatestMessageContextValue | undefined +>(undefined); export function SubtasksProvider({ children }: { children: React.ReactNode }) { - const [tasks, setTasks] = useState>({}); + const [latestMessages, setLatestMessages] = useState< + Record + >({}); + + const setLatestMessage = useCallback((taskId: string, message: AIMessage) => { + setLatestMessages((prev) => + prev[taskId] === message ? prev : { ...prev, [taskId]: message }, + ); + }, []); + + const value = useMemo( + () => ({ latestMessages, setLatestMessage }), + [latestMessages, setLatestMessage], + ); + return ( - + {children} - + ); } -export function useSubtaskContext() { - const context = useContext(SubtaskContext); +function useLatestMessageContext(): LatestMessageContextValue { + const context = useContext(LatestMessageContext); if (context === undefined) { throw new Error( - "useSubtaskContext must be used within a SubtaskContext.Provider", + "useLatestMessageContext must be used within a SubtasksProvider", ); } return context; } -export function useSubtask(id: string) { - const { tasks } = useSubtaskContext(); - return tasks[id]; +/** Read the latest `task_running` AIMessage emitted for *taskId*, or `undefined`. */ +export function useLatestSubtaskMessage(taskId: string): AIMessage | undefined { + return useLatestMessageContext().latestMessages[taskId]; } -export function useUpdateSubtask() { - const { tasks, setTasks } = useSubtaskContext(); - const updateSubtask = useCallback( - (task: Partial & { id: string }) => { - tasks[task.id] = { ...tasks[task.id], ...task } as Subtask; - if (task.latestMessage) { - setTasks({ ...tasks }); - } - }, - [tasks, setTasks], - ); - return updateSubtask; +/** + * Register the latest streaming AIMessage for a task. Call this from a + * stream event handler (e.g. `onCustomEvent`), not from render. + */ +export function useUpdateLatestMessage(): ( + taskId: string, + message: AIMessage, +) => void { + return useLatestMessageContext().setLatestMessage; } diff --git a/frontend/src/core/tasks/derive.ts b/frontend/src/core/tasks/derive.ts new file mode 100644 index 0000000000..e9a7e2db7a --- /dev/null +++ b/frontend/src/core/tasks/derive.ts @@ -0,0 +1,57 @@ +import type { Message } from "@langchain/langgraph-sdk"; + +import { extractTextFromMessage } from "../messages/utils"; + +import { parseSubtaskResult } from "./subtask-result"; +import type { Subtask } from "./types"; + +/** + * Derive the subtask card map from the current thread message list. + * + * Bytedance/deer-flow issue #3147: the old data flow built this map by + * calling `updateSubtask` *during render* from `MessageList`, which silently + * mutated the SubtaskContext object without triggering a re-render (only + * the SSE `latestMessage` path called `setState`). That worked by accident + * but is exactly the render-time mutation React Strict Mode warns about + * and the kind of pattern that masks card-stuck regressions like + * `#3107` BUG-007. + * + * Replace it with a pure function over the message list. The result is + * passed into `SubtasksProvider` via `setBaseTasksFromMessages`, batched + * inside an effect, so render stays read-only. The SSE-driven + * `latestMessage` path stays separate (see `useUpdateLatestMessage`). + */ +export function buildSubtaskMapFromMessages( + messages: readonly Message[], +): Record { + const tasks: Record = {}; + + for (const message of messages) { + if (message.type === "ai") { + for (const toolCall of message.tool_calls ?? []) { + if (toolCall.name !== "task" || !toolCall.id) continue; + // Seed the card in `in_progress` the moment the AI emits the + // tool_call. The matching ToolMessage flips it to terminal below. + tasks[toolCall.id] = { + id: toolCall.id, + subagent_type: String(toolCall.args?.subagent_type ?? ""), + description: String(toolCall.args?.description ?? ""), + prompt: String(toolCall.args?.prompt ?? ""), + status: "in_progress", + }; + } + } else if (message.type === "tool") { + const taskId = message.tool_call_id; + if (!taskId || !(taskId in tasks)) continue; + // NOTE: `parseSubtaskResult` will gain a second argument for + // ``additional_kwargs.subagent_status`` once #3146 lands. This call + // site is forward-compatible: when the signature widens, we just + // pass `message.additional_kwargs` here and the structured field + // will take precedence over text parsing automatically. + const parsed = parseSubtaskResult(extractTextFromMessage(message)); + tasks[taskId] = { ...tasks[taskId], ...parsed } as Subtask; + } + } + + return tasks; +} diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 1c927c5ffc..bf20648277 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -13,7 +13,7 @@ import { getBackendBaseURL } from "../config"; import { useI18n } from "../i18n/hooks"; import type { FileInMessage } from "../messages/utils"; import type { LocalSettings } from "../settings"; -import { useUpdateSubtask } from "../tasks/context"; +import { useUpdateLatestMessage } from "../tasks/context"; import type { UploadedFileInfo } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads"; @@ -231,7 +231,7 @@ export function useThreadStream({ }, []); const queryClient = useQueryClient(); - const updateSubtask = useUpdateSubtask(); + const updateLatestMessage = useUpdateLatestMessage(); const thread = useStream({ client: getAPIClient(isMock), @@ -326,7 +326,7 @@ export function useThreadStream({ task_id: string; message: AIMessage; }; - updateSubtask({ id: e.task_id, latestMessage: e.message }); + updateLatestMessage(e.task_id, e.message); return; } diff --git a/frontend/tests/unit/core/tasks/context.test.tsx b/frontend/tests/unit/core/tasks/context.test.tsx new file mode 100644 index 0000000000..afe58a5c71 --- /dev/null +++ b/frontend/tests/unit/core/tasks/context.test.tsx @@ -0,0 +1,117 @@ +import type { AIMessage } from "@langchain/langgraph-sdk"; +import { act, render, renderHook, screen } from "@testing-library/react"; +import * as React from "react"; +import { StrictMode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + SubtasksProvider, + useLatestSubtaskMessage, + useUpdateLatestMessage, +} from "@/core/tasks/context"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function wrap({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("SubtasksProvider — latest message context", () => { + it("returns undefined until the SSE handler registers a message", () => { + const { result } = renderHook(() => useLatestSubtaskMessage("task-x"), { + wrapper: wrap, + }); + expect(result.current).toBeUndefined(); + }); + + it("publishes the registered AIMessage to consumers", () => { + const message = { + id: "msg-1", + type: "ai", + content: "subagent thinking", + } as unknown as AIMessage; + + const { result, rerender } = renderHook( + () => ({ + update: useUpdateLatestMessage(), + msg: useLatestSubtaskMessage("task-y"), + }), + { wrapper: wrap }, + ); + + act(() => { + result.current.update("task-y", message); + }); + rerender(); + + expect(result.current.msg).toBe(message); + }); + + it("keys messages by task id without bleeding across tasks", () => { + const a = { id: "a", type: "ai", content: "a" } as unknown as AIMessage; + const b = { id: "b", type: "ai", content: "b" } as unknown as AIMessage; + + const { result, rerender } = renderHook( + () => ({ + update: useUpdateLatestMessage(), + msgA: useLatestSubtaskMessage("task-a"), + msgB: useLatestSubtaskMessage("task-b"), + }), + { wrapper: wrap }, + ); + + act(() => { + result.current.update("task-a", a); + result.current.update("task-b", b); + }); + rerender(); + + expect(result.current.msgA).toBe(a); + expect(result.current.msgB).toBe(b); + }); + + it("StrictMode double-render does not log 'Cannot update a component while rendering'", () => { + // Regression test for #3147: the old `useUpdateSubtask` mutated + // context state directly during render, which React Strict Mode's + // double-render surfaces as a console.error. The new API only + // exposes write paths that are intended to fire outside render + // (effect / event handler), so there is no legitimate way to + // trigger that warning from this provider any more. + const errors: unknown[] = []; + const spy = vi.spyOn(console, "error").mockImplementation((...args) => { + errors.push(args); + }); + + function Reader() { + const msg = useLatestSubtaskMessage("task-z"); + return {msg ? "have-msg" : "no-msg"}; + } + + render( + + + + + , + ); + + expect(screen.getByText("no-msg")).toBeTruthy(); + + const renderWarnings = errors.filter( + (entry) => + Array.isArray(entry) && + entry.some( + (msg) => + typeof msg === "string" && + /Cannot update a component .* while rendering a different component/i.test( + msg, + ), + ), + ); + expect(renderWarnings).toHaveLength(0); + + spy.mockRestore(); + }); +}); diff --git a/frontend/tests/unit/core/tasks/derive-subtask-map.test.ts b/frontend/tests/unit/core/tasks/derive-subtask-map.test.ts new file mode 100644 index 0000000000..2087ce6fd6 --- /dev/null +++ b/frontend/tests/unit/core/tasks/derive-subtask-map.test.ts @@ -0,0 +1,155 @@ +import type { AIMessage, Message } from "@langchain/langgraph-sdk"; +import { describe, expect, it } from "vitest"; + +import { buildSubtaskMapFromMessages } from "@/core/tasks/derive"; + +function aiWithTaskCall(id: string, args: Record): AIMessage { + return { + id, + type: "ai", + content: "", + tool_calls: [ + { + id, + name: "task", + args, + }, + ], + } as unknown as AIMessage; +} + +function toolReturn(toolCallId: string, content: string): Message { + return { + id: `t-${toolCallId}`, + type: "tool", + content, + name: "task", + tool_call_id: toolCallId, + } as unknown as Message; +} + +describe("buildSubtaskMapFromMessages", () => { + it("returns empty map when there are no subagent task calls", () => { + expect(buildSubtaskMapFromMessages([])).toEqual({}); + }); + + it("seeds a card as in_progress as soon as the AI emits the task call", () => { + const map = buildSubtaskMapFromMessages([ + aiWithTaskCall("task-1", { + subagent_type: "general-purpose", + description: "Run echo command", + prompt: "echo hi", + }), + ]); + expect(map["task-1"]!).toEqual({ + id: "task-1", + subagent_type: "general-purpose", + description: "Run echo command", + prompt: "echo hi", + status: "in_progress", + }); + }); + + it("flips to completed when the matching tool message arrives", () => { + const map = buildSubtaskMapFromMessages([ + aiWithTaskCall("task-1", { + subagent_type: "general-purpose", + description: "Run echo command", + prompt: "echo hi", + }), + toolReturn("task-1", "Task Succeeded. Result: payload"), + ]); + expect(map["task-1"]!.status).toBe("completed"); + expect(map["task-1"]!.result).toBe("payload"); + }); + + it("flips to failed when the task tool returns an Error: wrapper", () => { + const map = buildSubtaskMapFromMessages([ + aiWithTaskCall("task-2", { + subagent_type: "general-purpose", + description: "Run thing", + prompt: "do thing", + }), + toolReturn( + "task-2", + "Error: Tool 'task' failed with TypeError: oops. Continue with available context, or choose an alternative tool.", + ), + ]); + expect(map["task-2"]!.status).toBe("failed"); + expect(map["task-2"]!.error).toContain("TypeError"); + }); + + it("keeps in_progress when there is no matching tool message yet", () => { + const map = buildSubtaskMapFromMessages([ + aiWithTaskCall("task-3", { + subagent_type: "general-purpose", + description: "x", + prompt: "y", + }), + ]); + expect(map["task-3"]!.status).toBe("in_progress"); + }); + + it("handles multiple subagent task calls in the same AI message", () => { + const msg = { + id: "ai-multi", + type: "ai", + content: "", + tool_calls: [ + { + id: "call-a", + name: "task", + args: { + subagent_type: "general-purpose", + description: "alpha", + prompt: "p1", + }, + }, + { + id: "call-b", + name: "task", + args: { + subagent_type: "general-purpose", + description: "beta", + prompt: "p2", + }, + }, + ], + } as unknown as AIMessage; + + const map = buildSubtaskMapFromMessages([ + msg, + toolReturn("call-a", "Task Succeeded. Result: A"), + ]); + expect(Object.keys(map).sort()).toEqual(["call-a", "call-b"]); + expect(map["call-a"]!.status).toBe("completed"); + expect(map["call-b"]!.status).toBe("in_progress"); + }); + + it("ignores non-task tool calls", () => { + const msg = { + id: "ai-shell", + type: "ai", + content: "", + tool_calls: [ + { + id: "call-shell", + name: "bash", + args: { command: "ls" }, + }, + ], + } as unknown as AIMessage; + + const map = buildSubtaskMapFromMessages([ + msg, + toolReturn("call-shell", "ok"), + ]); + expect(map).toEqual({}); + }); + + // The structured `additional_kwargs.subagent_status` path is owned by + // #3146 / PR #3154. This derive function is forward-compatible: as soon + // as `parseSubtaskResult` grows the second parameter, the structured + // path will win here automatically. The end-to-end coverage of that + // wiring lives in `subtask-result.test.ts` once #3146 ships. +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index ec8b8de31c..de6ee49a53 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ }, }, test: { - include: ["tests/unit/**/*.test.ts"], + include: ["tests/unit/**/*.test.{ts,tsx}"], + environment: "jsdom", + globals: true, }, }); From 66fed108a5b972bbfe313fc5446b69ae51a6328d Mon Sep 17 00:00:00 2001 From: fancyboi999 <135568692+fancyboi999@users.noreply.github.com> Date: Sun, 24 May 2026 17:01:12 +0800 Subject: [PATCH 2/3] fix(test): scope jsdom to context test and refresh docblocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `/* @vitest-environment node */` to prompt-input-files test so Blob.text() returns real bytes instead of jsdom's broken "[object Blob]" stringification — keeps the global jsdom env that context.test.tsx needs while restoring the upload helper coverage. - Drop stale references to the removed `setBaseTasksFromMessages` effect from derive.ts and message-list.tsx docblocks so the comments match the prop-driven derivation flow shipped with #3147. --- .../src/components/workspace/messages/message-list.tsx | 9 ++++----- frontend/src/core/tasks/derive.ts | 6 +++--- .../tests/unit/core/uploads/prompt-input-files.test.ts | 7 +++++++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 537142ebb4..e97397b679 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -363,11 +363,10 @@ export function MessageList({ ); } else if (group.type === "assistant:subagent") { - // The subtask context is fed by `derivedSubtasks` / the effect - // above — render only consumes it. Collect the per-group task - // *references* (used downstream to build subtask cards and - // count rendered subtasks) directly from the AI tool_calls so - // we do not mutate any shared state here. + // `derivedSubtasks` is computed once via `useMemo` above from + // the thread message list. Render only reads from it; the + // per-group task *references* below come from the AI + // tool_calls so no shared state is mutated here. const tasks = new Set(); for (const message of group.messages) { if (message.type !== "ai") continue; diff --git a/frontend/src/core/tasks/derive.ts b/frontend/src/core/tasks/derive.ts index e9a7e2db7a..e2397f6cdc 100644 --- a/frontend/src/core/tasks/derive.ts +++ b/frontend/src/core/tasks/derive.ts @@ -16,9 +16,9 @@ import type { Subtask } from "./types"; * and the kind of pattern that masks card-stuck regressions like * `#3107` BUG-007. * - * Replace it with a pure function over the message list. The result is - * passed into `SubtasksProvider` via `setBaseTasksFromMessages`, batched - * inside an effect, so render stays read-only. The SSE-driven + * Replace it with a pure function over the message list. `MessageList` + * computes the map inside a `useMemo` and hands each entry to + * `SubtaskCard` as a prop, so render stays read-only. The SSE-driven * `latestMessage` path stays separate (see `useUpdateLatestMessage`). */ export function buildSubtaskMapFromMessages( diff --git a/frontend/tests/unit/core/uploads/prompt-input-files.test.ts b/frontend/tests/unit/core/uploads/prompt-input-files.test.ts index 28c578bd34..7578dbb620 100644 --- a/frontend/tests/unit/core/uploads/prompt-input-files.test.ts +++ b/frontend/tests/unit/core/uploads/prompt-input-files.test.ts @@ -1,3 +1,10 @@ +/** + * @vitest-environment node + * + * jsdom's `Blob.prototype.text()` is broken (returns the literal + * "[object Blob]") so the data-URL/file rewrap assertions below have + * to run under the real Node Web Blob. + */ import { afterEach, expect, test, vi } from "vitest"; import { From aa5fd8f8b91525434589f2f5d5df2de874622070 Mon Sep 17 00:00:00 2001 From: fancyboi999 <135568692+fancyboi999@users.noreply.github.com> Date: Sun, 24 May 2026 18:02:51 +0800 Subject: [PATCH 3/3] fix(agents/new): wrap with SubtasksProvider so useThreadStream stops crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agents/new/page.tsx:89` calls `useThreadStream`, which now reaches into the SubtasksProvider via `useUpdateLatestMessage`. The new-agent wizard had no SubtasksProvider on its ancestor chain, so after the fail-loud guard landed in this PR the route returned HTTP 500 with `This page couldn't load` on every visit. The bug was latent on main: the previous `SubtaskContext` default value was a no-op object, so the wizard read it silently — subtask state was already dead on that page, just invisibly so. Tightening the context contract turned "silent dead" into "page crash". Adding the layout fixes both: the wizard renders again, *and* subtask state actually works there now. Same Provider stack as `agents/[agent_name]/chats/[thread_id]/layout.tsx`, so the wizard can transition into a live thread without an unmount/remount. Verified locally: - `pnpm typecheck` + `pnpm check` clean - `pnpm test --run`: 21 files / 122 tests - `make dev` + browser: `/workspace/agents/new` returns HTTP 200, 0 console errors, no 500s in `logs/frontend.log` (was 500 + `useLatestMessageContext must be used within a SubtasksProvider` before this commit). --- .../src/app/workspace/agents/new/layout.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 frontend/src/app/workspace/agents/new/layout.tsx diff --git a/frontend/src/app/workspace/agents/new/layout.tsx b/frontend/src/app/workspace/agents/new/layout.tsx new file mode 100644 index 0000000000..bc7ebaa129 --- /dev/null +++ b/frontend/src/app/workspace/agents/new/layout.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { PromptInputProvider } from "@/components/ai-elements/prompt-input"; +import { ArtifactsProvider } from "@/components/workspace/artifacts"; +import { SubtasksProvider } from "@/core/tasks/context"; + +// `page.tsx` calls `useThreadStream`, which reaches into the SubtasksProvider +// via `useUpdateLatestMessage`. Without this layout the context throws and the +// new-agent wizard renders the Next.js error boundary (`This page couldn't +// load`). Same Provider stack as the sibling chat layouts so the agent setup +// flow can transition into a live thread without an unmount/remount. +export default function NewAgentLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +}