diff --git a/web/src/hooks/chat/useSchedulerView.ts b/web/src/hooks/chat/useSchedulerView.ts index 5b406e4..8e08d7e 100644 --- a/web/src/hooks/chat/useSchedulerView.ts +++ b/web/src/hooks/chat/useSchedulerView.ts @@ -75,11 +75,25 @@ export function useSchedulerView(): UseSchedulerViewReturn { setSchedulerView(null); }, []); - /** Tier 1 路由:调度器视图激活时,chat 消息追加到 schedulerView;非 chat 消息 fall through */ + /** Tier 1 路由:调度器视图激活时,仅接收属于该定时任务会话的历史消息。 + * 实时流式消息(stream_delta/stream_end)必然属于当前正在执行的会话,不走历史视图; + * 带 topic_id 的消息属于实时执行的会话(历史加载的消息不带 topic_id),也不进入。 + * 两类消息均 fall through 到 Tier 2/3 路由到主视图,避免"正在执行的会话泄露到定时任务视图"。 */ const handleSchedulerMessage = useCallback((message: WsOutbound): boolean => { const currentSchedulerView = schedulerViewRef.current; if (!currentSchedulerView) return false; + // 实时流式消息不进入调度器历史视图(历史加载不会产生流式) + if (message.type === 'stream_delta' || message.type === 'stream_end') { + return false; + } + + // 带 topic_id 的消息属于实时执行的会话,不进入历史视图 + const topicId = (message as { topic_id?: string }).topic_id; + if (topicId) { + return false; + } + const chatMsg = serverMessageToChatMessage(message); if (chatMsg) { setSchedulerView((prev) => diff --git a/web/src/hooks/useChat.test.ts b/web/src/hooks/useChat.test.ts index ad32b61..515fffa 100644 --- a/web/src/hooks/useChat.test.ts +++ b/web/src/hooks/useChat.test.ts @@ -388,6 +388,39 @@ describe('useChat - handleServerMessage characterization', () => { expect(result.current.messages.find((m) => m.id === 'm1')).toBeUndefined(); }); + it('16. scheduler view active: realtime stream_delta and topic-tagged messages do NOT leak into schedulerView', () => { + const { result } = renderUseChat(); + const lookup: SchedulerJobSessionLookup = { channel: 'scheduler', chat_id: 'job-chat' }; + act(() => result.current.enterSchedulerJobView(lookup, 'job1', 'job desc')); + expect(result.current.schedulerView).not.toBeNull(); + + // 实时流式消息不应进入历史视图(即使 schedulerView 激活) + const realtimeStream: StreamDelta = { + type: 'stream_delta', + id: 'leak1', + delta: 'leaking content', + topic_id: 't-realtime', + }; + act(() => result.current.handleServerMessage(realtimeStream)); + expect(result.current.schedulerView?.messages.find((m) => m.id === 'leak1')).toBeUndefined(); + + // 带 topic_id 的 assistant_response 也不应进入历史视图 + const taggedAssistant: AssistantResponse = { + type: 'assistant_response', + id: 'leak2', + content: 'tagged response', + role: 'assistant', + topic_id: 't-realtime', + }; + act(() => result.current.handleServerMessage(taggedAssistant)); + expect(result.current.schedulerView?.messages.find((m) => m.id === 'leak2')).toBeUndefined(); + + // 无 topic_id 的历史消息仍正常进入(保持用例 11 的行为) + act(() => result.current.handleServerMessage(assistantResponse)); + expect(result.current.schedulerView?.messages).toHaveLength(1); + expect(result.current.schedulerView?.messages[0].content).toBe('Hello world'); + }); + it('12. tool_result with tool_name=todo_write triggers a list_todos command in main view', () => { const { result, sendMessage } = renderUseChat(); const todoWriteResult: ToolResult = {