{sessionsForThisWorkspace.length === 0 && (
暂无会话
diff --git a/web/src/components/panels/FileChangePanel.test.tsx b/web/src/components/panels/FileChangePanel.test.tsx
index 1f4222e5..8e553908 100644
--- a/web/src/components/panels/FileChangePanel.test.tsx
+++ b/web/src/components/panels/FileChangePanel.test.tsx
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
+ act,
fireEvent,
render,
screen,
@@ -267,6 +268,73 @@ describe("FileChangePanel", () => {
});
});
+ it("rolls back only remaining file changes after one file was already restored", async () => {
+ useUIStore.setState({
+ fileChanges: [
+ {
+ id: "fc-a",
+ path: "src/a.txt",
+ status: "modified",
+ additions: 1,
+ deletions: 0,
+ checkpoint_id: "cp-1",
+ rollback_checkpoint_id: "cp-rollback-1",
+ hunks: [],
+ },
+ {
+ id: "fc-b",
+ path: "src/b.txt",
+ status: "modified",
+ additions: 1,
+ deletions: 0,
+ checkpoint_id: "cp-1",
+ rollback_checkpoint_id: "cp-rollback-1",
+ hunks: [],
+ },
+ ],
+ } as never);
+
+ render(
);
+
+ act(() => {
+ useUIStore.setState({
+ fileChanges: [
+ {
+ id: "fc-b",
+ path: "src/b.txt",
+ status: "modified",
+ additions: 1,
+ deletions: 0,
+ checkpoint_id: "cp-1",
+ rollback_checkpoint_id: "cp-rollback-1",
+ hunks: [],
+ },
+ ],
+ } as never);
+ });
+
+ fireEvent.click(screen.getByTestId("restore-all-changes"));
+ const confirmButtons = screen.getAllByRole("button", {
+ name: "Rollback all",
+ });
+ fireEvent.click(confirmButtons[confirmButtons.length - 1]);
+
+ await waitFor(() => {
+ expect(mockGatewayAPI.restoreCheckpoint).toHaveBeenCalledWith({
+ session_id: "sess-1",
+ checkpoint_id: "cp-rollback-1",
+ mode: "baseline",
+ paths: ["src/b.txt"],
+ });
+ });
+ expect(mockGatewayAPI.restoreCheckpoint).not.toHaveBeenCalledWith({
+ session_id: "sess-1",
+ checkpoint_id: "cp-rollback-1",
+ mode: "baseline",
+ paths: ["src/a.txt", "src/b.txt"],
+ });
+ });
+
it("disables accept and restore actions while the session is generating", () => {
useChatStore.setState({ isGenerating: true } as never);
render(
);
diff --git a/web/src/index.css b/web/src/index.css
index 23cd50a7..b3688670 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -1153,6 +1153,26 @@ html, body, #root {
transition: all var(--duration-fast) var(--ease-out);
}
.input-box:focus-within { background: var(--bg-elevated); border-color: var(--accent-muted); }
+.input-box.compacting {
+ border-color: var(--warning-muted);
+}
+.compact-status-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px 0;
+ color: var(--warning);
+ font-size: 12px;
+ font-weight: 500;
+}
+.compact-status-spinner {
+ flex-shrink: 0;
+ animation: compact-spin 900ms linear infinite;
+}
+@keyframes compact-spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
.input-box textarea {
width: 100%;
padding: 10px 14px 6px;
diff --git a/web/src/stores/useChatStore.test.ts b/web/src/stores/useChatStore.test.ts
index 6325e9d3..4c561244 100644
--- a/web/src/stores/useChatStore.test.ts
+++ b/web/src/stores/useChatStore.test.ts
@@ -5,6 +5,9 @@ beforeEach(() => {
useChatStore.setState({
messages: [],
isGenerating: false,
+ isCompacting: false,
+ compactMode: '',
+ compactMessage: '',
streamingMessageId: '',
streamingThinkingMessageId: '',
permissionRequests: [],
@@ -30,6 +33,45 @@ describe('useChatStore', () => {
expect(useChatStore.getState().messages[0].content).toBe('hello')
})
+ it('setMessages replaces messages atomically', () => {
+ const store = useChatStore.getState()
+ store.addMessage({ id: 'old', role: 'user', content: 'old', type: 'text', timestamp: 1 })
+
+ store.setMessages([
+ { id: 'new-1', role: 'user', content: 'first', type: 'text', timestamp: 2 },
+ { id: 'new-2', role: 'assistant', content: 'second', type: 'text', timestamp: 3 },
+ ])
+
+ expect(useChatStore.getState().messages.map((m) => m.id)).toEqual(['new-1', 'new-2'])
+ })
+
+ it('setMessages preserves unrelated chat state', () => {
+ const store = useChatStore.getState()
+ store.setGenerating(true)
+ store.addPermissionRequest({
+ request_id: 'r1',
+ tool_name: 'filesystem_read_file',
+ tool_category: 'filesystem',
+ action_type: 'read',
+ operation: 'read',
+ target_type: 'file',
+ target: 'README.md',
+ } as any)
+ store.updateTokenUsage({ input_tokens: 1, output_tokens: 2, total_tokens: 3 } as any)
+ store.setPhase('running')
+ store.setStopReason('manual')
+
+ store.setMessages([{ id: 'hist', role: 'assistant', content: 'loaded', type: 'text', timestamp: 4 }])
+
+ const state = useChatStore.getState()
+ expect(state.messages.map((m) => m.id)).toEqual(['hist'])
+ expect(state.isGenerating).toBe(true)
+ expect(state.permissionRequests).toHaveLength(1)
+ expect(state.tokenUsage).toEqual({ input_tokens: 1, output_tokens: 2, total_tokens: 3 })
+ expect(state.phase).toBe('running')
+ expect(state.stopReason).toBe('manual')
+ })
+
it('appendChunk concatenates to streaming message', () => {
const store = useChatStore.getState()
store.addMessage({ id: 'stream-1', role: 'assistant', content: 'Hel', type: 'text', timestamp: 1 })
@@ -123,6 +165,32 @@ describe('useChatStore', () => {
expect(useChatStore.getState().isGenerating).toBe(false)
})
+ it('tracks compact state independently from generation', () => {
+ const store = useChatStore.getState()
+ store.setGenerating(true)
+ store.startCompacting('manual', 'Compacting context...')
+
+ expect(useChatStore.getState().isGenerating).toBe(true)
+ expect(useChatStore.getState().isCompacting).toBe(true)
+ expect(useChatStore.getState().compactMode).toBe('manual')
+ expect(useChatStore.getState().compactMessage).toBe('Compacting context...')
+
+ store.finishCompacting()
+
+ expect(useChatStore.getState().isGenerating).toBe(true)
+ expect(useChatStore.getState().isCompacting).toBe(false)
+ expect(useChatStore.getState().compactMode).toBe('')
+ })
+
+ it('resetGeneratingState clears stuck compact state', () => {
+ const store = useChatStore.getState()
+ store.startCompacting('manual', 'Compacting context...')
+ store.resetGeneratingState()
+
+ expect(useChatStore.getState().isCompacting).toBe(false)
+ expect(useChatStore.getState().compactMessage).toBe('')
+ })
+
it('starts with default permission mode', () => {
expect(useChatStore.getState().permissionMode).toBe('default')
})
@@ -135,7 +203,9 @@ describe('useChatStore', () => {
it('clearMessages resets permission mode to default', () => {
const store = useChatStore.getState()
store.setPermissionMode('bypass')
+ store.startCompacting('manual', 'Compacting context...')
store.clearMessages()
expect(useChatStore.getState().permissionMode).toBe('default')
+ expect(useChatStore.getState().isCompacting).toBe(false)
})
})
diff --git a/web/src/stores/useChatStore.ts b/web/src/stores/useChatStore.ts
index b7126d25..53a9a3f0 100644
--- a/web/src/stores/useChatStore.ts
+++ b/web/src/stores/useChatStore.ts
@@ -49,6 +49,12 @@ interface ChatState {
messages: ChatMessage[]
/** 是否正在生成 */
isGenerating: boolean
+ /** 当前会话是否正在执行上下文压缩。 */
+ isCompacting: boolean
+ /** 当前压缩触发模式,用于展示压缩来源。 */
+ compactMode: string
+ /** 压缩期间展示给用户的持续状态文案。 */
+ compactMessage: string
/** 当前 AI 回复缓冲 ID(流式追加用) */
streamingMessageId: string
/** 当前 thinking 流式消息 ID */
@@ -72,6 +78,7 @@ interface ChatState {
// Actions
addMessage: (msg: ChatMessage) => void
+ setMessages: (messages: ChatMessage[]) => void
removeMessage: (id: string) => void
/** 从指定消息(含)开始截断 messages 数组并清理生成相关状态 */
truncateFromMessage: (messageId: string) => void
@@ -100,6 +107,8 @@ interface ChatState {
/** 更新一条 verification 消息的 data(verification 进行中持续更新同一条消息) */
updateVerificationMessage: (messageId: string, data: VerificationRunRecord) => void
setGenerating: (v: boolean) => void
+ startCompacting: (mode?: string, message?: string) => void
+ finishCompacting: () => void
setStreamingMessageId: (id: string) => void
/** 重置生成状态:终结当前流式消息 + 清除 isGenerating */
resetGeneratingState: () => void
@@ -187,6 +196,9 @@ function createThinkingMessage(): ChatMessage {
export const useChatStore = create
((set) => ({
messages: [],
isGenerating: false,
+ isCompacting: false,
+ compactMode: '',
+ compactMessage: '',
streamingMessageId: '',
streamingThinkingMessageId: '',
permissionRequests: [],
@@ -199,6 +211,7 @@ export const useChatStore = create((set) => ({
permissionMode: 'default',
addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
+ setMessages: (messages) => set({ messages: [...messages] }),
removeMessage: (id) => set((s) => ({ messages: s.messages.filter((m) => m.id !== id) })),
truncateFromMessage: (messageId) =>
@@ -210,6 +223,9 @@ export const useChatStore = create((set) => ({
streamingMessageId: '',
streamingThinkingMessageId: '',
isGenerating: false,
+ isCompacting: false,
+ compactMode: '',
+ compactMessage: '',
permissionRequests: [],
pendingUserQuestion: null,
phase: '',
@@ -356,6 +372,18 @@ export const useChatStore = create((set) => ({
})),
setGenerating: (isGenerating) => set({ isGenerating }),
+ startCompacting: (compactMode = 'manual', compactMessage = 'Compacting context...') =>
+ set({
+ isCompacting: true,
+ compactMode,
+ compactMessage,
+ }),
+ finishCompacting: () =>
+ set({
+ isCompacting: false,
+ compactMode: '',
+ compactMessage: '',
+ }),
setStreamingMessageId: (streamingMessageId) => set({ streamingMessageId }),
/** 重置生成状态:终结当前流式消息 + 清除 isGenerating */
@@ -379,6 +407,9 @@ export const useChatStore = create((set) => ({
streamingMessageId: '',
streamingThinkingMessageId: '',
isGenerating: false,
+ isCompacting: false,
+ compactMode: '',
+ compactMessage: '',
}
}),
@@ -427,6 +458,9 @@ export const useChatStore = create((set) => ({
streamingMessageId: '',
streamingThinkingMessageId: '',
isGenerating: false,
+ isCompacting: false,
+ compactMode: '',
+ compactMessage: '',
permissionRequests: [],
pendingUserQuestion: null,
tokenUsage: null,
diff --git a/web/src/stores/useSessionStore.test.ts b/web/src/stores/useSessionStore.test.ts
index 3f3703f0..89022593 100644
--- a/web/src/stores/useSessionStore.test.ts
+++ b/web/src/stores/useSessionStore.test.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { useSessionStore } from './useSessionStore'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { mapHistoryMessages, useSessionStore } from './useSessionStore'
import { useChatStore } from './useChatStore'
import { useGatewayStore } from './useGatewayStore'
import { useRuntimeInsightStore } from './useRuntimeInsightStore'
@@ -11,7 +11,100 @@ beforeEach(() => {
useRuntimeInsightStore.getState().reset()
})
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
describe('useSessionStore', () => {
+ it('mapHistoryMessages skips internal system acceptance reminders', () => {
+ const mapped = mapHistoryMessages([
+ { role: 'user', content: 'start' },
+ {
+ role: 'system',
+ content: [
+ '',
+ 'pending_todo',
+ '',
+ ].join(''),
+ },
+ { role: 'assistant', content: 'visible answer' },
+ ])
+
+ expect(mapped.map((m) => m.content)).toEqual(['start', 'visible answer'])
+ expect(mapped.every((m) => m.content.includes('acceptance_continue') === false)).toBe(true)
+ })
+
+ it('mapHistoryMessages skips leaked assistant acceptance control text', () => {
+ const mapped = mapHistoryMessages([
+ {
+ role: 'assistant',
+ content: '',
+ },
+ { role: 'assistant', content: 'normal assistant text' },
+ {
+ role: 'assistant',
+ content: 'prefix pending_todo',
+ },
+ ])
+
+ expect(mapped.map((m) => m.content)).toEqual([
+ 'normal assistant text',
+ 'prefix pending_todo',
+ ])
+ })
+
+ it('mapHistoryMessages keeps tool results that contain acceptance-like text', () => {
+ const mapped = mapHistoryMessages([
+ {
+ role: 'assistant',
+ content: '',
+ tool_calls: [
+ { id: 'call-xml', name: 'filesystem_read_file', arguments: '{"path":"fixture.xml"}' },
+ ],
+ },
+ {
+ role: 'tool',
+ content: 'literal fixture\n',
+ tool_call_id: 'call-xml',
+ },
+ ])
+
+ expect(mapped).toHaveLength(1)
+ expect(mapped[0]).toMatchObject({
+ role: 'tool',
+ type: 'tool_call',
+ toolCallId: 'call-xml',
+ toolResult: 'literal fixture\n',
+ toolStatus: 'done',
+ })
+ })
+
+ it('mapHistoryMessages keeps normal messages and merges tool results', () => {
+ const mapped = mapHistoryMessages([
+ { role: 'user', content: 'please inspect' },
+ {
+ role: 'assistant',
+ content: 'calling tool',
+ tool_calls: [
+ { id: 'call-1', name: 'filesystem_read_file', arguments: '{"path":"README.md"}' },
+ ],
+ },
+ { role: 'tool', content: 'file content', tool_call_id: 'call-1' },
+ ])
+
+ expect(mapped).toHaveLength(3)
+ expect(mapped[0]).toMatchObject({ role: 'user', type: 'text', content: 'please inspect' })
+ expect(mapped[1]).toMatchObject({ role: 'assistant', type: 'text', content: 'calling tool' })
+ expect(mapped[2]).toMatchObject({
+ role: 'tool',
+ type: 'tool_call',
+ toolName: 'filesystem_read_file',
+ toolCallId: 'call-1',
+ toolResult: 'file content',
+ toolStatus: 'done',
+ })
+ })
+
it('createSession clears messages and resets session state', () => {
useChatStore.getState().addMessage({ id: '1', role: 'user', content: 'hello', type: 'text', timestamp: 1 })
useSessionStore.setState({ currentSessionId: 'sess-1' })
@@ -54,6 +147,8 @@ describe('useSessionStore', () => {
})
it('switchSession binds stream and loads session data', async () => {
+ const setMessagesSpy = vi.spyOn(useChatStore.getState(), 'setMessages')
+ const addMessageSpy = vi.spyOn(useChatStore.getState(), 'addMessage')
const mockBindStream = vi.fn().mockResolvedValue({})
const mockLoadSession = vi.fn().mockResolvedValue({
payload: {
@@ -67,11 +162,15 @@ describe('useSessionStore', () => {
await useSessionStore.getState().switchSession('sess-2', mockAPI)
expect(mockBindStream).toHaveBeenCalledWith({ session_id: 'sess-2', channel: 'all' })
+ expect(setMessagesSpy).toHaveBeenCalledTimes(1)
+ expect(addMessageSpy).not.toHaveBeenCalled()
expect(useChatStore.getState().messages).toHaveLength(1)
expect(useChatStore.getState().messages[0].role).toBe('user')
})
it('fetchSessions auto-selects first session and binds stream', async () => {
+ const setMessagesSpy = vi.spyOn(useChatStore.getState(), 'setMessages')
+ const addMessageSpy = vi.spyOn(useChatStore.getState(), 'addMessage')
const mockListSessions = vi.fn().mockResolvedValue({
payload: {
sessions: [{
@@ -83,13 +182,18 @@ describe('useSessionStore', () => {
},
})
const mockBindStream = vi.fn().mockResolvedValue({})
- const mockLoadSession = vi.fn().mockResolvedValue({ payload: { messages: [] } })
+ const mockLoadSession = vi.fn().mockResolvedValue({
+ payload: { messages: [{ role: 'assistant', content: 'loaded history', tool_calls: [] }] },
+ })
const mockAPI = { listSessions: mockListSessions, bindStream: mockBindStream, loadSession: mockLoadSession } as any
await useSessionStore.getState().fetchSessions(mockAPI)
expect(useSessionStore.getState().currentSessionId).toBe('sess-a')
expect(mockBindStream).toHaveBeenCalledWith({ session_id: 'sess-a', channel: 'all' })
+ expect(setMessagesSpy).toHaveBeenCalled()
+ expect(addMessageSpy).not.toHaveBeenCalled()
+ expect(useChatStore.getState().messages[0]).toMatchObject({ role: 'assistant', content: 'loaded history' })
})
it('fetchSessions does not auto-select when current session is valid', async () => {
diff --git a/web/src/stores/useSessionStore.ts b/web/src/stores/useSessionStore.ts
index 10755991..d6b8a847 100644
--- a/web/src/stores/useSessionStore.ts
+++ b/web/src/stores/useSessionStore.ts
@@ -157,12 +157,23 @@ export async function loadSessionWithInsights(gatewayAPI: GatewayAPI, sessionId:
return sessionFrame
}
+/** isInternalHistoryMessage 识别仅供 runtime/provider 续跑使用、不能回放到 Web 聊天流的内部控制消息。 */
+function isInternalHistoryMessage(msg: BackendMessage): boolean {
+ const role = msg.role.trim().toLowerCase()
+ if (role !== 'system' && role !== 'assistant') return false
+
+ const content = msg.content.trim()
+ if (!content) return false
+ return /^$/.test(content)
+}
+
/** 将后端历史消息映射为前端 ChatMessage 列表,正确合并 tool_result 回 tool_call */
export function mapHistoryMessages(backendMessages: BackendMessage[]): Array['messages'][0]> {
let _idCounter = 0
// Phase 1: Collect tool results by tool_call_id
const toolResults = new Map()
for (const msg of backendMessages) {
+ if (isInternalHistoryMessage(msg)) continue
if (msg.tool_call_id) {
toolResults.set(msg.tool_call_id, { content: msg.content, isError: !!msg.is_error })
}
@@ -171,6 +182,8 @@ export function mapHistoryMessages(backendMessages: BackendMessage[]): Array['messages'][0]> = []
for (const msg of backendMessages) {
+ if (isInternalHistoryMessage(msg)) continue
+
// Skip bare tool result messages — they are merged into tool_call messages
if (msg.tool_call_id) continue
@@ -241,9 +254,7 @@ export async function reloadSessionAfterCheckpointRestore(
if (sessionData.messages && sessionData.messages.length > 0) {
const mapped = mapHistoryMessages(sessionData.messages)
- for (const msg of mapped) {
- useChatStore.getState().addMessage(msg)
- }
+ useChatStore.getState().setMessages(mapped)
}
const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build'
@@ -308,9 +319,7 @@ export const useSessionStore = create((set, get) => ({
// 5. Load messages and stop transitioning
if (sessionData.messages && sessionData.messages.length > 0) {
const mapped = mapHistoryMessages(sessionData.messages)
- for (const msg of mapped) {
- useChatStore.getState().addMessage(msg)
- }
+ useChatStore.getState().setMessages(mapped)
}
// 恢复会话的 agent_mode
const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build'
@@ -404,9 +413,7 @@ export const useSessionStore = create((set, get) => ({
const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string }
if (sessionData.messages && sessionData.messages.length > 0) {
const mapped = mapHistoryMessages(sessionData.messages)
- for (const msg of mapped) {
- useChatStore.getState().addMessage(msg)
- }
+ useChatStore.getState().setMessages(mapped)
}
const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build'
useChatStore.getState().setAgentMode(restoredMode)
diff --git a/web/src/utils/eventBridge.test.ts b/web/src/utils/eventBridge.test.ts
index 0f5abdbd..00574db3 100644
--- a/web/src/utils/eventBridge.test.ts
+++ b/web/src/utils/eventBridge.test.ts
@@ -25,6 +25,9 @@ beforeEach(() => {
useChatStore.setState({
messages: [],
isGenerating: false,
+ isCompacting: false,
+ compactMode: "",
+ compactMessage: "",
streamingMessageId: "",
permissionRequests: [],
pendingUserQuestion: null,
@@ -538,6 +541,85 @@ describe("eventBridge", () => {
expect(useRuntimeInsightStore.getState().budgetUsageRatio).toBe(0.8);
});
+ it("CompactStart sets persistent compact state without a toast", () => {
+ const api = createMockGatewayAPI();
+ handleGatewayEvent(
+ {
+ type: EventType.CompactStart,
+ payload: {
+ payload: {
+ runtime_event_type: EventType.CompactStart,
+ payload: "manual",
+ },
+ },
+ session_id: "sess-1",
+ run_id: "run-1",
+ },
+ api,
+ );
+
+ expect(useChatStore.getState().isCompacting).toBe(true);
+ expect(useChatStore.getState().compactMode).toBe("manual");
+ expect(useChatStore.getState().compactMessage).toBe(
+ "Compacting context...",
+ );
+ expect(useUIStore.getState().toasts).toHaveLength(0);
+ });
+
+ it("CompactApplied clears compact state and shows completion toast", () => {
+ const api = createMockGatewayAPI();
+ useChatStore
+ .getState()
+ .startCompacting("manual", "Compacting context...");
+
+ handleGatewayEvent(
+ {
+ type: EventType.CompactApplied,
+ payload: {
+ payload: {
+ runtime_event_type: EventType.CompactApplied,
+ payload: { applied: true },
+ },
+ },
+ session_id: "sess-1",
+ run_id: "run-1",
+ },
+ api,
+ );
+
+ expect(useChatStore.getState().isCompacting).toBe(false);
+ expect(useUIStore.getState().toasts.at(-1)?.message).toBe(
+ "Context compacted",
+ );
+ });
+
+ it("CompactError clears compact state and uses payload message", () => {
+ const api = createMockGatewayAPI();
+ useChatStore
+ .getState()
+ .startCompacting("manual", "Compacting context...");
+
+ handleGatewayEvent(
+ {
+ type: EventType.CompactError,
+ payload: {
+ payload: {
+ runtime_event_type: EventType.CompactError,
+ payload: { message: "compact timed out" },
+ },
+ },
+ session_id: "sess-1",
+ run_id: "run-1",
+ },
+ api,
+ );
+
+ expect(useChatStore.getState().isCompacting).toBe(false);
+ expect(useUIStore.getState().toasts.at(-1)?.message).toBe(
+ "compact timed out",
+ );
+ });
+
it("VerificationStageFinished upserts verifier status", () => {
const api = createMockGatewayAPI();
handleGatewayEvent(
@@ -868,6 +950,196 @@ describe("eventBridge", () => {
expect(useUIStore.getState().fileChanges).toHaveLength(0);
});
+ it("baseline CheckpointRestored removes only restored file changes without reloading the session", async () => {
+ const loadSession = vi.fn(async () => ({
+ payload: {
+ id: "sess-1",
+ agent_mode: "build",
+ messages: [{ role: "assistant", content: "after restore" }],
+ },
+ }));
+ const api = createMockGatewayAPI({ loadSession });
+ useSessionStore.setState({ currentSessionId: "sess-1" } as any);
+ useUIStore.setState({
+ isRestoringCheckpoint: true,
+ fileChanges: [
+ {
+ id: "fc-1",
+ path: "src/a.txt",
+ status: "modified",
+ additions: 1,
+ deletions: 0,
+ },
+ {
+ id: "fc-2",
+ path: "src/b.txt",
+ status: "modified",
+ additions: 2,
+ deletions: 1,
+ },
+ ],
+ } as any);
+
+ handleGatewayEvent(
+ {
+ type: EventType.CheckpointRestored,
+ payload: {
+ payload: {
+ runtime_event_type: EventType.CheckpointRestored,
+ payload: {
+ checkpoint_id: "cp1",
+ session_id: "sess-1",
+ guard_checkpoint_id: "",
+ mode: "baseline",
+ paths: ["./src/a.txt"],
+ },
+ },
+ },
+ session_id: "sess-1",
+ run_id: "run-1",
+ },
+ api,
+ );
+ await Promise.resolve();
+
+ expect(loadSession).not.toHaveBeenCalled();
+ expect(useUIStore.getState().isRestoringCheckpoint).toBe(false);
+ expect(useUIStore.getState().fileChanges.map((entry) => entry.path)).toEqual(
+ ["src/b.txt"],
+ );
+ });
+
+ it("baseline CheckpointRestored removes all paths from rollback all events", async () => {
+ const loadSession = vi.fn();
+ const api = createMockGatewayAPI({ loadSession });
+ useSessionStore.setState({ currentSessionId: "sess-1" } as any);
+ useUIStore.setState({
+ isRestoringCheckpoint: true,
+ fileChanges: [
+ {
+ id: "fc-1",
+ path: "src/a.txt",
+ status: "modified",
+ additions: 1,
+ deletions: 0,
+ },
+ {
+ id: "fc-2",
+ path: "src/b.txt",
+ status: "added",
+ additions: 2,
+ deletions: 0,
+ },
+ ],
+ } as any);
+
+ handleGatewayEvent(
+ {
+ type: EventType.CheckpointRestored,
+ payload: {
+ payload: {
+ runtime_event_type: EventType.CheckpointRestored,
+ payload: {
+ checkpoint_id: "cp1",
+ session_id: "sess-1",
+ guard_checkpoint_id: "",
+ mode: "baseline",
+ paths: ["src/a.txt", "src/b.txt"],
+ },
+ },
+ },
+ session_id: "sess-1",
+ run_id: "run-1",
+ },
+ api,
+ );
+ await Promise.resolve();
+
+ expect(loadSession).not.toHaveBeenCalled();
+ expect(useUIStore.getState().fileChanges).toHaveLength(0);
+ });
+
+ it("baseline CheckpointRestored invalidates in-flight run-scoped file change refreshes", async () => {
+ let resolveDiff: ((value: unknown) => void) | undefined;
+ const checkpointDiff = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveDiff = resolve;
+ }),
+ );
+ const loadSession = vi.fn();
+ const api = createMockGatewayAPI({ checkpointDiff, loadSession });
+ useSessionStore.setState({ currentSessionId: "sess-1" } as any);
+ useGatewayStore.setState({ currentRunId: "run-1" } as any);
+ useUIStore.setState({
+ fileChanges: [
+ {
+ id: "fc-1",
+ path: "src/a.txt",
+ status: "modified",
+ additions: 1,
+ deletions: 0,
+ },
+ ],
+ } as any);
+
+ handleGatewayEvent(
+ {
+ type: EventType.CheckpointCreated,
+ payload: {
+ payload: {
+ runtime_event_type: EventType.CheckpointCreated,
+ payload: {
+ checkpoint_id: "cp-end",
+ code_checkpoint_ref: "c",
+ session_checkpoint_ref: "s",
+ commit_hash: "",
+ reason: "end_of_turn",
+ },
+ },
+ },
+ session_id: "sess-1",
+ run_id: "run-1",
+ },
+ api,
+ );
+ expect(checkpointDiff).toHaveBeenCalled();
+
+ handleGatewayEvent(
+ {
+ type: EventType.CheckpointRestored,
+ payload: {
+ payload: {
+ runtime_event_type: EventType.CheckpointRestored,
+ payload: {
+ checkpoint_id: "cp-end",
+ session_id: "sess-1",
+ guard_checkpoint_id: "",
+ mode: "baseline",
+ paths: ["src/a.txt"],
+ },
+ },
+ },
+ session_id: "sess-1",
+ run_id: "run-1",
+ },
+ api,
+ );
+
+ resolveDiff?.({
+ payload: {
+ checkpoint_id: "cp-end",
+ files: { modified: ["src/a.txt"] },
+ patch: "--- a/src/a.txt\n+++ b/src/a.txt\n@@ -1 +1 @@\n-old\n+new\n",
+ },
+ });
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(loadSession).not.toHaveBeenCalled();
+ expect(useUIStore.getState().fileChanges).toHaveLength(0);
+ });
+
it("CheckpointRestored invalidates in-flight run-scoped file change refreshes", async () => {
let resolveDiff: ((value: unknown) => void) | undefined;
const checkpointDiff = vi.fn(
diff --git a/web/src/utils/eventBridge.ts b/web/src/utils/eventBridge.ts
index 9310046f..7ce7ccc4 100644
--- a/web/src/utils/eventBridge.ts
+++ b/web/src/utils/eventBridge.ts
@@ -409,6 +409,26 @@ function _refreshRunFileChanges(
});
}
+// applyBaselineCheckpointRestoreEvent 只同步文件级 baseline 回退,不刷新会话消息或 insight。
+function applyBaselineCheckpointRestoreEvent(payload: CheckpointRestoredPayload) {
+ const restoredPaths = new Set(
+ (payload.paths ?? []).map(normalizeFilePath).filter(Boolean),
+ );
+ _latestRunDiffRequestId += 1;
+ useUIStore.getState().setRestoringCheckpoint(false);
+ if (restoredPaths.size === 0) {
+ return;
+ }
+ for (const path of restoredPaths) {
+ _firstTouchRollbackCheckpointByPath.delete(path);
+ }
+ useUIStore.setState((state) => ({
+ fileChanges: state.fileChanges.filter(
+ (change) => !restoredPaths.has(normalizeFilePath(change.path)),
+ ),
+ }));
+}
+
// refreshSessionAfterCheckpointRestoreEvent 仅在当前会话收到 restore/undo 事件时刷新会话与文件变更视图。
function refreshSessionAfterCheckpointRestoreEvent(
gatewayAPI: GatewayAPI,
@@ -831,18 +851,31 @@ export function handleGatewayEvent(
break;
case EventType.CompactStart: {
- uiStore.showToast("Compacting context...", "info");
+ const mode =
+ typeof eventPayload === "string"
+ ? eventPayload
+ : strField(eventPayload, "trigger_mode") ||
+ strField(eventPayload, "TriggerMode") ||
+ "manual";
+ useChatStore
+ .getState()
+ .startCompacting(mode, "Compacting context...");
break;
}
case EventType.CompactApplied: {
+ useChatStore.getState().finishCompacting();
uiStore.showToast("Context compacted", "success");
break;
}
case EventType.CompactError: {
+ useChatStore.getState().finishCompacting();
uiStore.showToast(
- (eventPayload as string) ?? "Compaction failed",
+ strField(eventPayload, "message") ||
+ strField(eventPayload, "Message") ||
+ (typeof eventPayload === "string" ? eventPayload : "") ||
+ "Compaction failed",
"error",
);
break;
@@ -1068,6 +1101,11 @@ export function handleGatewayEvent(
if (
payload.session_id === useSessionStore.getState().currentSessionId
) {
+ if (payload.mode === "baseline") {
+ applyBaselineCheckpointRestoreEvent(payload);
+ uiStore.showToast("File rollback completed", "success");
+ break;
+ }
chatStore.markAllCheckpointsRestored();
refreshSessionAfterCheckpointRestoreEvent(
gatewayAPI,