diff --git a/.github/workflows/codegraph-e2e-votes.yml b/.github/workflows/codegraph-e2e-votes.yml index 0c44824cb40..6ea310c4a6a 100644 --- a/.github/workflows/codegraph-e2e-votes.yml +++ b/.github/workflows/codegraph-e2e-votes.yml @@ -121,6 +121,10 @@ jobs: if: steps.cache-client-package.outputs.cache-hit != 'true' run: npm run build:client-package + # About a second, so it is not cached; the client app imports the built entries. + - name: Build chat package + run: npm run build:chat + - name: Restore client app build cache id: cache-client-app uses: actions/cache@v5 diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml index fba49e174c9..682b410dec5 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -211,8 +211,8 @@ jobs: if: steps.cache-client-package.outputs.cache-hit != 'true' run: npm run build:client-package - # About a second, so it is not cached. Nothing downstream consumes the output yet; the step - # proves the package's tsdown build, declarations and exports on every frontend run. + # About a second, so it is not cached. The client imports the package's built entries, so + # the type check, unit tests and Vite build below download this output. - name: Build chat package run: npm run build:chat @@ -230,6 +230,13 @@ jobs: path: packages/client/dist retention-days: 2 + - name: Upload chat-package build + uses: actions/upload-artifact@v6 + with: + name: build-chat-package + path: packages/chat/dist + retention-days: 2 + typecheck: name: TypeScript type checks (client) needs: build @@ -271,6 +278,12 @@ jobs: name: build-client-package path: packages/client/dist + - name: Download chat-package build + uses: actions/download-artifact@v7 + with: + name: build-chat-package + path: packages/chat/dist + - name: Type check client run: npm run typecheck working-directory: client @@ -446,6 +459,12 @@ jobs: name: build-client-package path: packages/client/dist + - name: Download chat-package build + uses: actions/download-artifact@v7 + with: + name: build-chat-package + path: packages/chat/dist + - name: Run unit tests (shard ${{ matrix.shard }}/2) env: SELECTED: ${{ needs.codegraph_select.outputs.client_files }} @@ -523,5 +542,11 @@ jobs: name: build-client-package path: packages/client/dist + - name: Download chat-package build + uses: actions/download-artifact@v7 + with: + name: build-chat-package + path: packages/chat/dist + - name: Build client run: cd client && npm run build:ci diff --git a/.github/workflows/frontend-windows-nightly.yml b/.github/workflows/frontend-windows-nightly.yml index a8ad82a87ce..d08cccea5c3 100644 --- a/.github/workflows/frontend-windows-nightly.yml +++ b/.github/workflows/frontend-windows-nightly.yml @@ -75,6 +75,9 @@ jobs: if: steps.cache-client-package.outputs.cache-hit != 'true' run: npm run build:client-package + - name: Build chat package + run: npm run build:chat + - name: Upload data-provider build uses: actions/upload-artifact@v6 with: @@ -89,6 +92,13 @@ jobs: path: packages/client/dist retention-days: 2 + - name: Upload chat-package build + uses: actions/upload-artifact@v6 + with: + name: build-chat-package + path: packages/chat/dist + retention-days: 2 + test-windows: name: 'Tests: Windows (shard ${{ matrix.shard }}/4)' needs: build @@ -136,6 +146,12 @@ jobs: name: build-client-package path: packages/client/dist + - name: Download chat-package build + uses: actions/download-artifact@v7 + with: + name: build-chat-package + path: packages/chat/dist + - name: Run unit tests (shard ${{ matrix.shard }}/4) run: npm run test:ci -- --shard=${{ matrix.shard }}/4 working-directory: client diff --git a/.github/workflows/playwright-bombadil.yml b/.github/workflows/playwright-bombadil.yml index 9354eedc189..1c4b61d165d 100644 --- a/.github/workflows/playwright-bombadil.yml +++ b/.github/workflows/playwright-bombadil.yml @@ -109,6 +109,10 @@ jobs: if: steps.cache-client-package.outputs.cache-hit != 'true' run: npm run build:client-package + # About a second, so it is not cached; the client app imports the built entries. + - name: Build chat package + run: npm run build:chat + - name: Restore client app build cache id: cache-client-app uses: actions/cache@v4 diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 74b6110538d..909e2689d46 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -34,6 +34,7 @@ module.exports = { '^test/(.*)$': '/test/$1', '^~/(.*)$': '/src/$1', '^@librechat/client$': '/../packages/client/dist/index.cjs', + '^@librechat/chat$': '/../packages/chat/dist/index.cjs', '^librechat-data-provider/react-query$': '/../node_modules/librechat-data-provider/src/react-query', }, diff --git a/client/package.json b/client/package.json index 6831d650cdf..2691a5020e8 100644 --- a/client/package.json +++ b/client/package.json @@ -36,6 +36,7 @@ "@dicebear/core": "^9.4.3", "@headlessui/react": "^2.1.2", "@hyperdx/browser": "^0.24.0", + "@librechat/chat": "*", "@librechat/client": "*", "@marsidev/react-turnstile": "^1.1.0", "@mcp-ui/client": "^5.7.0", diff --git a/client/src/Providers/ChatTransportContext.tsx b/client/src/Providers/ChatTransportContext.tsx index 4eb5ba02bf0..acc7b59f95d 100644 --- a/client/src/Providers/ChatTransportContext.tsx +++ b/client/src/Providers/ChatTransportContext.tsx @@ -1,7 +1,7 @@ import { createContext, useContext } from 'react'; +import { createSSETransport } from '@librechat/chat'; import { EndpointURLs } from 'librechat-data-provider'; import type { Transport } from '~/hooks/Chat/contract'; -import { createSSETransport } from '~/hooks/SSE/transport'; import * as requests from '~/data-provider'; /** diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index d407db3cc76..a9668a47996 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -8,6 +8,20 @@ import { ContentTypes, getToolTimingDurations, } from 'librechat-data-provider'; +import { + applyReasoningDelta, + applyToolCallCompleted, + isSkillAuthoringToolCall, + applyToolCallDelta, + applySummarizeDelta, + applyRunStepClosed, + applyToolCallsStep, + applyMessageDelta, + finalizeSummaries, + applyAgentUpdate, + applySummaryStep, + getEditPrefix, +} from '@librechat/chat'; import type { Agents, TMessage, @@ -30,20 +44,6 @@ import { takeRegisteredSubagentProgressKeys, subagentProgressKey, } from '~/components/Chat/Subagents/state'; -import { - applyReasoningDelta, - applyToolCallCompleted, - isSkillAuthoringToolCall, - applyToolCallDelta, - applySummarizeDelta, - applyRunStepClosed, - applyToolCallsStep, - applyMessageDelta, - finalizeSummaries, - applyAgentUpdate, - applySummaryStep, - getEditPrefix, -} from './steps'; import { sandboxStartingByToolCallId, ptcTraceByToolCallId, diff --git a/client/src/utils/approval.ts b/client/src/utils/approval.ts index 8bc73f50eeb..48695f077d2 100644 --- a/client/src/utils/approval.ts +++ b/client/src/utils/approval.ts @@ -1,14 +1,15 @@ import { ContentTypes } from 'librechat-data-provider'; +import { + ASK_USER_QUESTION, + isAskUserQuestionPart, + markAskUserQuestionAnswered, + isAnsweredAskUserQuestionPart, +} from '@librechat/chat'; import type { Agents, TMessage, TMessageContentParts } from 'librechat-data-provider'; +import type { AskUserQuestionPart } from '@librechat/chat'; -/** - * UI-only content-part type used to render an `ask_user_question` pause inline - * with the assistant's other content parts. It rides on the standard `content` - * array (which `Agents.MessageContentComplex` allows for arbitrary `type` - * strings), so it survives SSE, sync, and DB rehydration without a new wire - * type or an extra field on `TMessage`. - */ -export const ASK_USER_QUESTION = 'ask_user_question' as const; +export { ASK_USER_QUESTION, isAskUserQuestionPart, isAnsweredAskUserQuestionPart }; +export type { AskUserQuestionPart } from '@librechat/chat'; /** * Answer sent when the user explicitly skips a question: the run must resume @@ -20,31 +21,6 @@ const ASK_USER_QUESTION_ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; const MAX_ASK_USER_QUESTIONS = 4; const MAX_ASK_USER_QUESTION_HEADER_LENGTH = 80; -/** Shape of the synthetic content part carrying an ask-user pending action. */ -export interface AskUserQuestionPart { - type: typeof ASK_USER_QUESTION; - [ASK_USER_QUESTION]: { - actionId: string; - question: Agents.AskUserQuestionRequest; - questions?: Agents.AskUserQuestionBatchItem[]; - /** The ask tool call that raised the pause (present from - * `@librechat/agents` > 3.3.8) — lets the answer stamp target the exact - * tool-call part in multi-ask turns. */ - tool_call_id?: string; - }; -} - -/** - * The synthetic type isn't in the `ContentTypes` union, so this reads the - * `type` field through a cast rather than a type-predicate (which TS rejects - * because `AskUserQuestionPart` isn't assignable to the strict - * `TMessageContentParts` union). - */ -export const isAskUserQuestionPart = (part: Partial | undefined): boolean => - (part as { type?: string } | undefined)?.type === ASK_USER_QUESTION && - part != null && - ASK_USER_QUESTION in part; - const getToolCallId = (part: TMessageContentParts | undefined): string => (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? ''; @@ -346,28 +322,11 @@ export function removeAskUserQuestionPart(message: TMessage, actionId: string): */ const submittedAskAnswers = new Map(); -/** - * Ask actions the user has answered this session. Same rationale as - * {@link submittedAskAnswers}: the SSE step handler evolves its own cached copy - * of the streaming message, so the store-level strip below can't reach it — - * writing that copy back would resurrect an answered card. Keyed by `actionId` - * and only ever added to by {@link resolveAskUserQuestionPart}, so a step event - * racing a still-LIVE pause can never mistake its card for an answered one. - */ -const answeredAskActionIds = new Set(); - /** The locally-submitted answer for an ask tool_call, if any. */ export function getSubmittedAskAnswer(toolCallId: string | undefined): string | undefined { return toolCallId ? submittedAskAnswers.get(toolCallId) : undefined; } -/** Whether `part` is an ask card whose question the user already answered. */ -export const isAnsweredAskUserQuestionPart = ( - part: Partial | undefined, -): boolean => - isAskUserQuestionPart(part) && - answeredAskActionIds.has((part as unknown as AskUserQuestionPart)[ASK_USER_QUESTION].actionId); - /** * Resolve an answered ask-user-question pause on the client, mirroring the * server's resume-time stamp so the durable Q&A card shows the answer the @@ -394,7 +353,7 @@ export function resolveAskUserQuestionPart( if (!syntheticPart) { return message; } - answeredAskActionIds.add(actionId); + markAskUserQuestionAnswered(actionId); /** Exact-attribution target when the payload carried the interrupting call's * id — several ask cards in one turn each resolve their own part. Absent * (older server/SDK), the newest-unanswered fallback below applies. */ diff --git a/e2e/specs/mock/scenarios/chat-package.spec.ts b/e2e/specs/mock/scenarios/chat-package.spec.ts index a976691ce92..ff2d8162a62 100644 --- a/e2e/specs/mock/scenarios/chat-package.spec.ts +++ b/e2e/specs/mock/scenarios/chat-package.spec.ts @@ -230,4 +230,41 @@ test.describe('the built @librechat/chat package', () => { expect(answer.react).toBe(resolve(dist, 'react.cjs')); expect(answer.components).toBe(resolve(dist, 'components.cjs')); }); + + test('the client resolves the stream core from the built package @scenario:the-client-resolves-the-stream-core-from-the-built-package', () => { + test.setTimeout(120_000); + const dist = resolve(repoRoot, 'packages/chat/dist'); + expect(existsSync(dist), 'packages/chat/dist is missing: run npm run build').toBe(true); + + /** Resolved from the client workspace, the way Vite and the client's jest see it, + * with every UI module blocked so the stream code proves it stays framework-free. */ + const probe = ` + const Module = require('node:module'); + const blocked = new Set(${JSON.stringify(UI_MODULES)}); + const resolveFilename = Module._resolveFilename; + Module._resolveFilename = function (request, ...rest) { + if (blocked.has(request)) throw new Error('the core reached ' + request); + return resolveFilename.call(this, request, ...rest); + }; + const core = require('@librechat/chat'); + const names = ['createSSETransport', 'normalizeFrame', 'applyMessageDelta', 'applyToolCallDelta', 'isAnsweredAskUserQuestionPart']; + const answer = { entry: require.resolve('@librechat/chat'), cjs: {}, esm: {} }; + for (const name of names) answer.cjs[name] = typeof core[name]; + import('@librechat/chat').then((esm) => { + for (const name of names) answer.esm[name] = typeof esm[name]; + process.stdout.write(JSON.stringify(answer)); + }); + `; + const loaded = run(process.execPath, ['-e', probe], { cwd: resolve(repoRoot, 'client') }); + expect(loaded.status, loaded.output).toBe(0); + const answer = JSON.parse(loaded.stdout) as { + entry: string; + cjs: Record; + esm: Record; + }; + expect(answer.entry).toBe(resolve(dist, 'index.cjs')); + for (const exports of [answer.cjs, answer.esm]) { + expect(Object.values(exports)).toEqual(Array(5).fill('function')); + } + }); }); diff --git a/e2e/specs/mock/scenarios/step-reducer.spec.ts b/e2e/specs/mock/scenarios/step-reducer.spec.ts index d0746792d9e..118aca7dce0 100644 --- a/e2e/specs/mock/scenarios/step-reducer.spec.ts +++ b/e2e/specs/mock/scenarios/step-reducer.spec.ts @@ -12,7 +12,7 @@ import { /** * Streamed content parts are folded into the response by the step reducers in - * `client/src/hooks/SSE/steps`, each at its step's index. These scenarios pin + * `packages/chat/src/core/steps`, each at its step's index. These scenarios pin * what that looks like on screen, live and after the persisted copy reloads. */ diff --git a/e2e/specs/mock/scenarios/tool-steps.spec.ts b/e2e/specs/mock/scenarios/tool-steps.spec.ts index c87ad5fa5e8..9754d8bb309 100644 --- a/e2e/specs/mock/scenarios/tool-steps.spec.ts +++ b/e2e/specs/mock/scenarios/tool-steps.spec.ts @@ -12,7 +12,7 @@ import { /** * A tool call is opened by its run step, grows through argument deltas and is * settled by its completion, all folded into one part at the step's index by - * the tool reducers in `client/src/hooks/SSE/steps/tools.ts`. + * the tool reducers in `packages/chat/src/core/steps/tools.ts`. */ const FINAL_TEXT = 'E2E execute_code complete'; diff --git a/eslint.config.mjs b/eslint.config.mjs index bdf63fc1044..bb55967c651 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -636,6 +636,15 @@ export default [ 'error', { sourceRoot: path.join(__dirname, 'packages/chat/src'), packageName: '@librechat/chat' }, ], + // The code moving in from `client/src` keeps the app's convention for intentionally unused names. + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, { diff --git a/package-lock.json b/package-lock.json index 19a4f002a8d..a9f124ce902 100644 --- a/package-lock.json +++ b/package-lock.json @@ -866,6 +866,7 @@ "@dicebear/core": "^9.4.3", "@headlessui/react": "^2.1.2", "@hyperdx/browser": "^0.24.0", + "@librechat/chat": "*", "@librechat/client": "*", "@marsidev/react-turnstile": "^1.1.0", "@mcp-ui/client": "^5.7.0", @@ -41543,6 +41544,9 @@ "packages/chat": { "name": "@librechat/chat", "version": "0.1.0", + "dependencies": { + "sse.js": "^2.8.0" + }, "devDependencies": { "@babel/preset-env": "^7.29.5", "@babel/preset-react": "^7.22.15", diff --git a/packages/chat/jest.config.js b/packages/chat/jest.config.js index 3bb01a29a8c..333952fa2d9 100644 --- a/packages/chat/jest.config.js +++ b/packages/chat/jest.config.js @@ -6,6 +6,8 @@ module.exports = { coverageReporters: ['text', 'cobertura'], maxWorkers, restoreMocks: true, + // sse.js ships ES modules only, so jest transforms it like the app's own sources. + transformIgnorePatterns: ['/node_modules/(?!sse\\.js/)'], // The core runs without a DOM; a React spec opts into jsdom with a docblock. testEnvironment: 'node', }; diff --git a/packages/chat/package.json b/packages/chat/package.json index db73d164163..2605ea4b3cb 100644 --- a/packages/chat/package.json +++ b/packages/chat/package.json @@ -60,6 +60,9 @@ "peerDependencies": { "librechat-data-provider": "*" }, + "dependencies": { + "sse.js": "^2.8.0" + }, "devDependencies": { "@babel/preset-env": "^7.29.5", "@babel/preset-react": "^7.22.15", diff --git a/client/src/hooks/SSE/__tests__/frames.spec.ts b/packages/chat/src/core/__tests__/frames.spec.ts similarity index 100% rename from client/src/hooks/SSE/__tests__/frames.spec.ts rename to packages/chat/src/core/__tests__/frames.spec.ts diff --git a/client/src/hooks/SSE/__tests__/sse.spec.ts b/packages/chat/src/core/__tests__/sse.spec.ts similarity index 100% rename from client/src/hooks/SSE/__tests__/sse.spec.ts rename to packages/chat/src/core/__tests__/sse.spec.ts diff --git a/client/src/hooks/SSE/__tests__/steps.spec.ts b/packages/chat/src/core/__tests__/steps.spec.ts similarity index 100% rename from client/src/hooks/SSE/__tests__/steps.spec.ts rename to packages/chat/src/core/__tests__/steps.spec.ts diff --git a/client/src/hooks/SSE/__tests__/transport.spec.ts b/packages/chat/src/core/__tests__/transport.spec.ts similarity index 100% rename from client/src/hooks/SSE/__tests__/transport.spec.ts rename to packages/chat/src/core/__tests__/transport.spec.ts diff --git a/packages/chat/src/core/ask.ts b/packages/chat/src/core/ask.ts new file mode 100644 index 00000000000..5a58c5deac4 --- /dev/null +++ b/packages/chat/src/core/ask.ts @@ -0,0 +1,57 @@ +import type { Agents, TMessageContentParts } from 'librechat-data-provider'; + +/** + * UI-only content-part type used to render an `ask_user_question` pause inline + * with the assistant's other content parts. It rides on the standard `content` + * array (which `Agents.MessageContentComplex` allows for arbitrary `type` + * strings), so it survives SSE, sync, and DB rehydration without a new wire + * type or an extra field on `TMessage`. + */ +export const ASK_USER_QUESTION = 'ask_user_question' as const; + +/** Shape of the synthetic content part carrying an ask-user pending action. */ +export interface AskUserQuestionPart { + type: typeof ASK_USER_QUESTION; + [ASK_USER_QUESTION]: { + actionId: string; + question: Agents.AskUserQuestionRequest; + questions?: Agents.AskUserQuestionBatchItem[]; + /** The ask tool call that raised the pause (present from + * `@librechat/agents` > 3.3.8); lets the answer stamp target the exact + * tool-call part in multi-ask turns. */ + tool_call_id?: string; + }; +} + +/** + * The synthetic type isn't in the `ContentTypes` union, so this reads the + * `type` field through a cast rather than a type-predicate (which TS rejects + * because `AskUserQuestionPart` isn't assignable to the strict + * `TMessageContentParts` union). + */ +export const isAskUserQuestionPart = (part: Partial | undefined): boolean => + (part as { type?: string } | undefined)?.type === ASK_USER_QUESTION && + part != null && + ASK_USER_QUESTION in part; + +/** + * Ask actions the user has answered this session. The SSE step handler evolves + * its own cached copy of the streaming message, so the store-level strip of an + * answered card can't reach it, and writing that copy back would resurrect the + * card. Keyed by `actionId` and only ever added to by + * {@link markAskUserQuestionAnswered}, so a step event racing a still-LIVE pause + * can never mistake its card for an answered one. + */ +const answeredAskActionIds = new Set(); + +/** Records that the user answered the ask action `actionId` this session. */ +export function markAskUserQuestionAnswered(actionId: string): void { + answeredAskActionIds.add(actionId); +} + +/** Whether `part` is an ask card whose question the user already answered. */ +export const isAnsweredAskUserQuestionPart = ( + part: Partial | undefined, +): boolean => + isAskUserQuestionPart(part) && + answeredAskActionIds.has((part as unknown as AskUserQuestionPart)[ASK_USER_QUESTION].actionId); diff --git a/packages/chat/src/core/index.ts b/packages/chat/src/core/index.ts index fcb073fefcd..e1a48410113 100644 --- a/packages/chat/src/core/index.ts +++ b/packages/chat/src/core/index.ts @@ -1 +1,4 @@ +export * from './ask'; +export * from './steps'; export * from './types'; +export * from './transport'; diff --git a/client/src/hooks/SSE/steps/content.ts b/packages/chat/src/core/steps/content.ts similarity index 99% rename from client/src/hooks/SSE/steps/content.ts rename to packages/chat/src/core/steps/content.ts index 728454e1fca..f85ff498a77 100644 --- a/client/src/hooks/SSE/steps/content.ts +++ b/packages/chat/src/core/steps/content.ts @@ -8,7 +8,7 @@ import type { SummaryContentPart, TMessageContentParts, } from 'librechat-data-provider'; -import { isAskUserQuestionPart, isAnsweredAskUserQuestionPart } from '~/utils/approval'; +import { isAskUserQuestionPart, isAnsweredAskUserQuestionPart } from '../ask'; /** * Pure content-part primitives behind `useStepHandler`. @@ -42,7 +42,7 @@ type AllContentTypes = | ContentTypes.SUMMARY | ContentTypes.ERROR; -export const isOAuthToolCallName = (name?: string) => +export const isOAuthToolCallName = (name?: string): boolean => typeof name === 'string' && name.startsWith(`oauth${Constants.mcp_delimiter}`); const isOAuthToolCallContent = (part?: Partial) => { diff --git a/client/src/hooks/SSE/steps/index.ts b/packages/chat/src/core/steps/index.ts similarity index 100% rename from client/src/hooks/SSE/steps/index.ts rename to packages/chat/src/core/steps/index.ts diff --git a/client/src/hooks/SSE/steps/text.ts b/packages/chat/src/core/steps/text.ts similarity index 100% rename from client/src/hooks/SSE/steps/text.ts rename to packages/chat/src/core/steps/text.ts diff --git a/client/src/hooks/SSE/steps/tools.ts b/packages/chat/src/core/steps/tools.ts similarity index 100% rename from client/src/hooks/SSE/steps/tools.ts rename to packages/chat/src/core/steps/tools.ts diff --git a/client/src/hooks/SSE/transport/frames.ts b/packages/chat/src/core/transport/frames.ts similarity index 100% rename from client/src/hooks/SSE/transport/frames.ts rename to packages/chat/src/core/transport/frames.ts diff --git a/client/src/hooks/SSE/transport/index.ts b/packages/chat/src/core/transport/index.ts similarity index 100% rename from client/src/hooks/SSE/transport/index.ts rename to packages/chat/src/core/transport/index.ts diff --git a/client/src/hooks/SSE/transport/sse.ts b/packages/chat/src/core/transport/sse.ts similarity index 100% rename from client/src/hooks/SSE/transport/sse.ts rename to packages/chat/src/core/transport/sse.ts diff --git a/packages/data-provider/src/parts.spec.ts b/packages/data-provider/src/parts.spec.ts index 7fe722d22b7..211dcdd9e43 100644 --- a/packages/data-provider/src/parts.spec.ts +++ b/packages/data-provider/src/parts.spec.ts @@ -118,7 +118,7 @@ const sparse = (length: number, slots: Record) => /** * Content arrays recorded from the stream reducers in - * `client/src/hooks/SSE/__tests__/steps.spec.ts` (text, tool calls, reasoning, handoff, summary, + * `packages/chat/src/core/__tests__/steps.spec.ts` (text, tool calls, reasoning, handoff, summary, * image scenarios), captured by running those reducers. Holes are where a later step arrived * before an earlier one. */