Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/codegraph-e2e-votes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions .github/workflows/frontend-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
16 changes: 16 additions & 0 deletions .github/workflows/frontend-windows-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions .github/workflows/playwright-bombadil.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions client/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ module.exports = {
'^test/(.*)$': '<rootDir>/test/$1',
'^~/(.*)$': '<rootDir>/src/$1',
'^@librechat/client$': '<rootDir>/../packages/client/dist/index.cjs',
'^@librechat/chat$': '<rootDir>/../packages/chat/dist/index.cjs',
'^librechat-data-provider/react-query$':
'<rootDir>/../node_modules/librechat-data-provider/src/react-query',
},
Expand Down
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion client/src/Providers/ChatTransportContext.tsx
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down
28 changes: 14 additions & 14 deletions client/src/hooks/SSE/useStepHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
61 changes: 10 additions & 51 deletions client/src/utils/approval.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<TMessageContentParts> | 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 ?? '';

Expand Down Expand Up @@ -346,28 +322,11 @@ export function removeAskUserQuestionPart(message: TMessage, actionId: string):
*/
const submittedAskAnswers = new Map<string, string>();

/**
* 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<string>();

/** 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<TMessageContentParts> | 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
Expand All @@ -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. */
Expand Down
37 changes: 37 additions & 0 deletions e2e/specs/mock/scenarios/chat-package.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
esm: Record<string, string>;
};
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'));
}
});
});
2 changes: 1 addition & 1 deletion e2e/specs/mock/scenarios/step-reducer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand Down
2 changes: 1 addition & 1 deletion e2e/specs/mock/scenarios/tool-steps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
9 changes: 9 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: '^_',
},
],
},
},
{
Expand Down
4 changes: 4 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/chat/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
3 changes: 3 additions & 0 deletions packages/chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading