🐛 fix(chat): unify message stream for /agent/:topicId and /page/:docId

Before this change a page-scoped conversation (FloatingChatPanel with
scope='page' in the /Page route) partitioned the client message store by
scope, so /agent/:topicId and /agent/:topicId/page/:docId each built their
own messagesMap slot and SWR cache — but the TRPC getMessages endpoint
ignores scope and returned the same messages for both, producing duplicate
fetches and a visible message-history split between the two surfaces.

Fixes by keeping scope='page' as a capability/surfacing marker only:
- messageMapKey: collapse 'page' to the default scope early in
  toMessageMapContext, so threadId/groupId still win and only the
  main/page pair actually unifies.
- useFetchMessages: build the SWR key from identity fields
  (agentId, groupId, threadId, topicId) instead of the full
  ConversationContext, so scope no longer partitions the cache.

agentConfigResolver/streamingExecutor/composeEnabledTools still read
scope='page' from operation.context for PageAgent injection and
initialContext.pageEditor wiring — the capability layer is unchanged.

Also fix two pre-existing test regressions surfaced by re-running the
impacted suites:
- streamingExecutor page-editor initialContext test now mocks
  pageAgentRuntime.isReady() (required since the PageAgent editor-ready
  guard landed).
- FloatingChatPanel default shell props test updated to match the
  [180,320,520,800] snap points introduced in 62dc91e444.
This commit is contained in:
Innei
2026-04-24 01:22:43 +08:00
parent 01ef7bc142
commit b650cdc9d7
5 changed files with 64 additions and 3 deletions
@@ -122,7 +122,7 @@ describe('FloatingChatPanel', () => {
it('applies default shell props', () => {
const { getByTestId } = render(<FloatingChatPanel agentId="a" topicId="t" />);
const sheet = getByTestId('floating-panel-shell');
expect(sheet.dataset.snapPoints).toBe(JSON.stringify([180, 320, 520]));
expect(sheet.dataset.snapPoints).toBe(JSON.stringify([180, 320, 520, 800]));
expect(sheet.dataset.variant).toBe('embedded');
expect(sheet.dataset.dismissible).toBe('false');
});
@@ -818,6 +818,7 @@ describe('StreamingExecutor actions', () => {
tools: [],
}),
} as any);
vi.spyOn(pageAgentRuntime, 'isReady').mockReturnValue(true);
vi.spyOn(pageAgentRuntime, 'getPageContentContext').mockReturnValue({
markdown: '# Test Document',
xml: '<root><h1>Test</h1></root>',
+18 -1
View File
@@ -111,8 +111,25 @@ export class MessageQueryActionImpl {
// Skip fetch when skipFetch is true or required fields are missing
const shouldFetch = !skipFetch && !!context.agentId && !!context.topicId;
// Only identity fields partition the server-side fetch. Keeping `scope`
// out of the SWR key lets main (`/agent/:topicId`) and page
// (`/agent/:topicId/page`) share one cache — the TRPC `getMessages`
// endpoint already ignores scope (see `MessageQueryContext`), so there's
// no reason to refetch per surface.
const swrKey = shouldFetch
? [
'CHAT_STORE_FETCH_MESSAGES',
{
agentId: context.agentId,
groupId: context.groupId,
threadId: context.threadId,
topicId: context.topicId,
},
]
: null;
return useClientDataSWRWithSync<UIChatMessage[]>(
shouldFetch ? ['CHAT_STORE_FETCH_MESSAGES', context] : null,
swrKey,
() => messageService.getMessages(context),
{
onData: (data) => {
@@ -270,6 +270,44 @@ describe('messageMapKey', () => {
});
});
describe('Page scope (collapses to main)', () => {
it('should reuse the main key for page scope on an existing topic', () => {
const result = messageMapKey({
scope: 'page',
agentId: 'agt_xxx',
topicId: 'tpc_yyy',
});
expect(result).toBe('main_agt_xxx_tpc_yyy');
});
it('should collapse page scope to main for a new topic', () => {
const result = messageMapKey({ scope: 'page', agentId: 'agt_xxx' });
expect(result).toBe('main_agt_xxx_new');
});
it('should produce the same key for scope=main and scope=page on the same topic', () => {
const mainKey = messageMapKey({ agentId: 'agt_xxx', topicId: 'tpc_yyy' });
const pageKey = messageMapKey({
scope: 'page',
agentId: 'agt_xxx',
topicId: 'tpc_yyy',
});
expect(pageKey).toBe(mainKey);
});
it('should still promote to thread scope when both page scope and threadId are present', () => {
// threadId wins over the page→main collapse, so thread-scoped messages
// (e.g. a thread opened from inside the page editor) stay isolated.
const result = messageMapKey({
scope: 'page',
agentId: 'agt_xxx',
topicId: 'tpc_yyy',
threadId: 'thd_zzz',
});
expect(result).toBe('thread_agt_xxx_tpc_yyy_thd_zzz');
});
});
describe('Edge cases', () => {
it('should handle null topicId as no topic (new)', () => {
const result = messageMapKey({ agentId: 'agt_xxx', topicId: null });
+6 -1
View File
@@ -41,7 +41,12 @@ export interface MessageMapKeyInput {
* Handles mapping from agentId/threadId to scopeId/subTopicId format
*/
const toMessageMapContext = (input: MessageMapKeyInput): MessageMapContext => {
const { agentId, topicId, threadId, isNew, scope, groupId, subAgentId } = input;
const { agentId, topicId, threadId, isNew, groupId, subAgentId } = input;
// 'page' is a surfacing/capability marker, not a conversation partition.
// Collapse it up-front so /agent/:topicId and /agent/:topicId/page share
// one key; threadId/groupId still win below, keeping threads isolated.
const scope = input.scope === 'page' ? undefined : input.scope;
// If threadId is present and scope is explicitly 'thread', use thread scope
// Thread scope takes priority when explicitly requested, even with groupId