diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index be03198f..cc7ac434 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -42,7 +42,6 @@ import { import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection"; import { perfLog } from "@/shared/lib/perfLog"; import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; -import { sanitizeReplayMessages } from "@/features/chat/lib/replaySanitizer"; import type { SkillInfo } from "@/features/skills/api/skills"; import { toChatSkillDraft } from "@/features/skills/lib/skillChatPrompt"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; @@ -186,17 +185,14 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const tFlush = performance.now(); useChatStore.getState().setSessionLoading(sessionId, false); const buffer = getAndDeleteReplayBuffer(sessionId); - const replayMessages = buffer - ? sanitizeReplayMessages(buffer) - : undefined; const replayStats = getReplayPerf(sessionId); clearReplayPerf(sessionId); - if (replayMessages) { - useChatStore.getState().setMessages(sessionId, replayMessages); + if (buffer && buffer.length > 0) { + useChatStore.getState().setMessages(sessionId, buffer); } const t2 = performance.now(); perfLog( - `[perf:load] ${sid} replay: notifs=${replayStats?.count ?? 0} span=${replayStats?.spanMs.toFixed(1) ?? "0"}ms msgs=${replayMessages?.length ?? 0} flush=${(t2 - tFlush).toFixed(1)}ms total=${(t2 - t0).toFixed(1)}ms`, + `[perf:load] ${sid} replay: notifs=${replayStats?.count ?? 0} span=${replayStats?.spanMs.toFixed(1) ?? "0"}ms msgs=${buffer?.length ?? 0} flush=${(t2 - tFlush).toFixed(1)}ms total=${(t2 - t0).toFixed(1)}ms`, ); } catch (err) { console.error("Failed to load session messages:", err); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts index 457783f8..c07ea053 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts @@ -90,14 +90,17 @@ describe("useChat compaction", () => { const messages = useChatStore.getState().messagesBySession["session-1"]; const runtime = useChatStore.getState().getSessionRuntime("session-1"); - expect(messages).toHaveLength(3); + expect(messages).toHaveLength(4); expect(messages[0]).toEqual( createTextMessage("user-1", "user", "Before compact"), ); expect(messages[1]).toEqual( + createTextMessage("compact-1", "user", "/compact/compact"), + ); + expect(messages[2]).toEqual( createTextMessage("assistant-1", "assistant", "After compact"), ); - expect(messages[2]).toMatchObject({ + expect(messages[3]).toMatchObject({ role: "system", content: [ { diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts deleted file mode 100644 index 2b5e1dc4..00000000 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useAgentStore } from "@/features/agents/stores/agentStore"; -import { useProjectStore } from "@/features/projects/stores/projectStore"; -import { useChatStore } from "../../stores/chatStore"; -import { useChatSessionStore } from "../../stores/chatSessionStore"; - -const mockSendMessage = vi.fn(); -const mockCompactConversation = vi.fn(); -const mockSetSelectedProvider = vi.fn(); -const mockResolveSessionCwd = vi.fn(); -const mockHandleProviderChange = vi.fn(); -const mockHandleModelChange = vi.fn(); -let mockSelectedAgentId = "goose"; -const INITIAL_TOKEN_STATE = { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - accumulatedInput: 0, - accumulatedOutput: 0, - accumulatedTotal: 0, - contextLimit: 0, -}; -let mockTokenState = { ...INITIAL_TOKEN_STATE }; -let capturedQueuedSend: - | (( - text: string, - overridePersona?: { id: string; name?: string }, - attachments?: unknown[], - ) => boolean | Promise) - | null = null; - -vi.mock("../useChat", () => ({ - useChat: () => ({ - messages: [], - chatState: "idle", - tokenState: mockTokenState, - sendMessage: (...args: unknown[]) => mockSendMessage(...args), - compactConversation: (...args: unknown[]) => - mockCompactConversation(...args), - stopStreaming: vi.fn(), - streamingMessageId: null, - }), -})); - -vi.mock("../useMessageQueue", () => ({ - useMessageQueue: (...args: unknown[]) => { - capturedQueuedSend = args[2] as typeof capturedQueuedSend; - return { - queuedMessage: null, - enqueue: vi.fn(), - dismiss: vi.fn(), - }; - }, -})); - -vi.mock("../useAutoCompactPreferences", () => ({ - useAutoCompactPreferences: () => ({ - autoCompactThreshold: 0.8, - isHydrated: true, - setAutoCompactThreshold: vi.fn(), - }), -})); - -vi.mock("../useResolvedAgentModelPicker", () => ({ - useResolvedAgentModelPicker: () => ({ - selectedAgentId: mockSelectedAgentId, - pickerAgents: [{ id: "goose", label: "Goose" }], - availableModels: [], - modelsLoading: false, - modelStatusMessage: null, - handleProviderChange: (providerId: string) => - mockHandleProviderChange(providerId), - handleModelChange: (modelId: string) => mockHandleModelChange(modelId), - effectiveModelSelection: { - id: "gpt-4o", - name: "GPT-4o", - providerId: "openai", - source: "explicit" as const, - }, - }), -})); - -vi.mock("@/features/agents/hooks/useProviderSelection", () => ({ - useProviderSelection: () => ({ - providers: [ - { id: "goose", label: "Goose" }, - { id: "openai", label: "OpenAI" }, - { id: "anthropic", label: "Anthropic" }, - ], - providersLoading: false, - selectedProvider: useAgentStore.getState().selectedProvider ?? "openai", - setSelectedProvider: (...args: unknown[]) => - mockSetSelectedProvider(...args), - }), -})); - -vi.mock("@/features/projects/lib/sessionCwdSelection", () => ({ - resolveSessionCwd: (...args: unknown[]) => mockResolveSessionCwd(...args), -})); - -import { useChatSessionController } from "../useChatSessionController"; - -describe("useChatSessionController compaction behavior", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockCompactConversation.mockResolvedValue("completed"); - mockResolveSessionCwd.mockResolvedValue("/tmp/project"); - mockTokenState = { ...INITIAL_TOKEN_STATE }; - capturedQueuedSend = null; - mockSelectedAgentId = "goose"; - - useAgentStore.setState({ - personas: [], - personasLoading: false, - agents: [], - agentsLoading: false, - providers: [], - providersLoading: false, - selectedProvider: "openai", - activeAgentId: null, - isLoading: false, - personaEditorOpen: false, - editingPersona: null, - }); - - useProjectStore.setState({ - projects: [], - loading: false, - activeProjectId: null, - }); - - useChatStore.setState({ - messagesBySession: {}, - sessionStateById: {}, - draftsBySession: {}, - queuedMessageBySession: {}, - scrollTargetMessageBySession: {}, - activeSessionId: null, - isConnected: true, - }); - - useChatSessionStore.setState({ - sessions: [ - { - id: "session-1", - title: "Chat", - providerId: "openai", - modelId: "gpt-4o", - modelName: "GPT-4o", - createdAt: "2026-04-20T00:00:00.000Z", - updatedAt: "2026-04-20T00:00:00.000Z", - messageCount: 0, - }, - ], - activeSessionId: null, - isLoading: false, - hasHydratedSessions: true, - contextPanelOpenBySession: {}, - activeWorkspaceBySession: {}, - }); - }); - - it("hides context usage until a fresh usage snapshot exists after switching models", () => { - const store = useChatStore.getState(); - store.replaceTokenState( - "session-1", - { - ...INITIAL_TOKEN_STATE, - contextLimit: 400_000, - }, - false, - ); - - const { result } = renderHook(() => - useChatSessionController({ sessionId: "session-1" }), - ); - - act(() => { - result.current.handleModelChange("claude-sonnet-4"); - }); - - const runtime = useChatStore.getState().getSessionRuntime("session-1"); - expect(runtime.hasUsageSnapshot).toBe(false); - expect(runtime.tokenState).toEqual(INITIAL_TOKEN_STATE); - }); - - it("hides context usage after switching models even when a snapshot existed", () => { - const store = useChatStore.getState(); - store.replaceTokenState( - "session-1", - { - ...INITIAL_TOKEN_STATE, - accumulatedTotal: 12_000, - contextLimit: 400_000, - }, - true, - ); - - const { result } = renderHook(() => - useChatSessionController({ sessionId: "session-1" }), - ); - - act(() => { - result.current.handleModelChange("claude-sonnet-4"); - }); - - const runtime = useChatStore.getState().getSessionRuntime("session-1"); - expect(runtime.hasUsageSnapshot).toBe(false); - expect(runtime.tokenState).toEqual(INITIAL_TOKEN_STATE); - }); - - it("hides pending home context usage after switching models", () => { - const store = useChatStore.getState(); - store.replaceTokenState( - "__home_pending__", - { - ...INITIAL_TOKEN_STATE, - accumulatedTotal: 12_000, - contextLimit: 400_000, - }, - true, - ); - - const { result } = renderHook(() => - useChatSessionController({ sessionId: null }), - ); - - act(() => { - result.current.handleModelChange("claude-sonnet-4"); - }); - - const runtime = useChatStore - .getState() - .getSessionRuntime("__home_pending__"); - expect(runtime.hasUsageSnapshot).toBe(false); - expect(runtime.tokenState).toEqual(INITIAL_TOKEN_STATE); - }); - - it("auto-compacts goose sessions before sending when the threshold is exceeded", async () => { - mockTokenState = { - ...INITIAL_TOKEN_STATE, - accumulatedTotal: 8_500, - contextLimit: 10_000, - }; - useChatStore - .getState() - .replaceTokenState("session-1", mockTokenState, true); - useChatSessionStore.getState().patchSession("session-1", { - providerId: "goose", - }); - - const { result } = renderHook(() => - useChatSessionController({ sessionId: "session-1" }), - ); - - await act(async () => { - await result.current.handleSend("hello"); - }); - - expect(mockCompactConversation).toHaveBeenCalledOnce(); - expect(mockSendMessage).toHaveBeenCalledWith("hello", undefined, undefined); - expect(mockCompactConversation.mock.invocationCallOrder[0]).toBeLessThan( - mockSendMessage.mock.invocationCallOrder[0], - ); - }); - - it("keeps compaction enabled for goose agent sessions backed by model providers", async () => { - mockTokenState = { - ...INITIAL_TOKEN_STATE, - accumulatedTotal: 8_500, - contextLimit: 10_000, - }; - useChatStore - .getState() - .replaceTokenState("session-1", mockTokenState, true); - - const { result } = renderHook(() => - useChatSessionController({ sessionId: "session-1" }), - ); - - expect(result.current.selectedProvider).toBe("goose"); - expect(result.current.supportsAutoCompactContext).toBe(true); - expect(result.current.supportsCompactionControls).toBe(true); - - await act(async () => { - await result.current.handleSend("hello"); - }); - - expect(mockCompactConversation).toHaveBeenCalledOnce(); - expect(mockSendMessage).toHaveBeenCalledWith("hello", undefined, undefined); - }); - - it("compacts the queued persona session before sending", async () => { - mockTokenState = { - ...INITIAL_TOKEN_STATE, - accumulatedTotal: 8_500, - contextLimit: 10_000, - }; - useChatStore - .getState() - .replaceTokenState("session-1", mockTokenState, true); - useChatSessionStore.getState().patchSession("session-1", { - providerId: "goose", - personaId: "persona-b", - }); - - renderHook(() => useChatSessionController({ sessionId: "session-1" })); - - expect(capturedQueuedSend).not.toBeNull(); - - await act(async () => { - await capturedQueuedSend?.("hello", { id: "persona-a" }); - }); - - expect(mockCompactConversation).toHaveBeenCalledWith({ id: "persona-a" }); - expect(mockSendMessage).toHaveBeenCalledWith( - "hello", - { id: "persona-a" }, - undefined, - ); - }); - - it("auto-compacts queued messages for goose personas even after switching away", async () => { - mockSelectedAgentId = "claude-acp"; - mockTokenState = { - ...INITIAL_TOKEN_STATE, - accumulatedTotal: 8_500, - contextLimit: 10_000, - }; - useChatStore - .getState() - .replaceTokenState("session-1", mockTokenState, true); - useAgentStore.setState({ - personas: [ - { - id: "persona-a", - displayName: "Persona A", - systemPrompt: "", - provider: "openai", - isBuiltin: false, - createdAt: "", - updatedAt: "", - }, - ], - }); - - renderHook(() => useChatSessionController({ sessionId: "session-1" })); - - await act(async () => { - await capturedQueuedSend?.("hello", { id: "persona-a" }); - }); - - expect(mockCompactConversation).toHaveBeenCalledWith({ id: "persona-a" }); - expect(mockSendMessage).toHaveBeenCalledWith( - "hello", - { id: "persona-a" }, - undefined, - ); - }); - - it("skips auto-compaction for queued messages targeting unsupported personas", async () => { - mockSelectedAgentId = "goose"; - mockTokenState = { - ...INITIAL_TOKEN_STATE, - accumulatedTotal: 8_500, - contextLimit: 10_000, - }; - useChatStore - .getState() - .replaceTokenState("session-1", mockTokenState, true); - useAgentStore.setState({ - personas: [ - { - id: "persona-a", - displayName: "Persona A", - systemPrompt: "", - provider: "claude-acp", - isBuiltin: false, - createdAt: "", - updatedAt: "", - }, - ], - }); - - renderHook(() => useChatSessionController({ sessionId: "session-1" })); - - await act(async () => { - await capturedQueuedSend?.("hello", { id: "persona-a" }); - }); - - expect(mockCompactConversation).not.toHaveBeenCalled(); - expect(mockSendMessage).toHaveBeenCalledWith( - "hello", - { id: "persona-a" }, - undefined, - ); - }); -}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts index 9ef2a119..99917d59 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts @@ -147,50 +147,4 @@ describe("useMessageQueue", () => { undefined, ); }); - - it("retries a queued message on the next idle transition after one failure", () => { - const sendMessage = vi - .fn() - .mockReturnValueOnce(false) - .mockReturnValueOnce(true); - useChatStore.getState().enqueueMessage("s1", { text: "queued" }); - - const { rerender } = renderHook( - ({ chatState }: { chatState: ChatState }) => - useMessageQueue("s1", chatState, sendMessage), - { initialProps: { chatState: "idle" as ChatState } }, - ); - - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(useChatStore.getState().queuedMessageBySession.s1).toEqual({ - text: "queued", - }); - - rerender({ chatState: "streaming" as const }); - rerender({ chatState: "idle" as const }); - - expect(sendMessage).toHaveBeenCalledTimes(2); - expect(useChatStore.getState().queuedMessageBySession.s1).toBeUndefined(); - }); - - it("stops auto-retrying the same queued message after repeated failures", () => { - const sendMessage = vi.fn().mockReturnValue(false); - useChatStore.getState().enqueueMessage("s1", { text: "queued" }); - - const { rerender } = renderHook( - ({ chatState }: { chatState: ChatState }) => - useMessageQueue("s1", chatState, sendMessage), - { initialProps: { chatState: "idle" as ChatState } }, - ); - - rerender({ chatState: "streaming" as const }); - rerender({ chatState: "idle" as const }); - rerender({ chatState: "streaming" as const }); - rerender({ chatState: "idle" as const }); - - expect(sendMessage).toHaveBeenCalledTimes(2); - expect(useChatStore.getState().queuedMessageBySession.s1).toEqual({ - text: "queued", - }); - }); }); diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index f62d6963..de835432 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -26,7 +26,6 @@ import { buildAcpImages, buildMessageAttachments, } from "../lib/attachments"; -import { sanitizeReplayMessages } from "../lib/replaySanitizer"; import { i18n } from "@/shared/i18n"; import type { ChatSendOptions } from "../types"; @@ -433,7 +432,7 @@ export function useChat( const buffer = getAndDeleteReplayBuffer(sessionId); if (buffer) { setMessages(sessionId, [ - ...sanitizeReplayMessages(buffer), + ...buffer, createCompactionConfirmationMessage(), ]); } else { diff --git a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts index ae73736c..0e48b100 100644 --- a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts +++ b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts @@ -3,7 +3,6 @@ import type { ChatAttachmentDraft } from "@/shared/types/messages"; import type { ChatSendOptions, ChatSkillDraft, ModelOption } from "../types"; import { INITIAL_TOKEN_STATE } from "@/shared/types/chat"; import { useChat } from "./useChat"; -import { useAutoCompactPreferences } from "./useAutoCompactPreferences"; import { useMessageQueue } from "./useMessageQueue"; import { useChatStore } from "../stores/chatStore"; import { useChatSessionStore } from "../stores/chatSessionStore"; @@ -20,11 +19,7 @@ import { } from "@/features/projects/lib/chatProjectContext"; import { setStoredModelPreference } from "../lib/modelPreferences"; import { applyLatestSessionConfig } from "../lib/sessionConfigRequests"; -import { - shouldAutoCompactContext, - supportsContextAutoCompaction, - supportsContextCompactionControls, -} from "../lib/autoCompact"; +import { supportsContextCompactionControls } from "../lib/autoCompact"; import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection"; import { useResolvedAgentModelPicker, @@ -102,8 +97,6 @@ export function useChatSessionController({ : undefined, ); const project = storedProject ?? null; - const { autoCompactThreshold, isHydrated: isAutoCompactThresholdHydrated } = - useAutoCompactPreferences(); const hasContextUsageSnapshot = useChatStore( (s) => s.sessionStateById[stateSessionId]?.hasUsageSnapshot ?? false, ); @@ -443,101 +436,9 @@ export function useChatSessionController({ }, ); const resolvedTokenState = tokenState ?? INITIAL_TOKEN_STATE; - const supportsAutoCompactContext = - supportsContextAutoCompaction(selectedAgentId); const supportsCompactionControls = supportsContextCompactionControls(selectedAgentId); const isCompactingContext = chatState === "compacting"; - const resolveAutoCompactAgentId = useCallback( - (overridePersona?: { id: string; name?: string }): string | null => { - if (!overridePersona?.id) { - return selectedAgentId; - } - - const targetPersona = personas.find( - (persona) => persona.id === overridePersona.id, - ); - if (!targetPersona?.provider) { - return selectedAgentId; - } - - const targetAgentId = resolveAgentProviderCatalogIdStrictFromEntries( - catalogEntries, - targetPersona.provider, - ); - if (targetAgentId) { - return targetAgentId; - } - - const isGooseModelProvider = providers.some( - (provider) => - provider.id === targetPersona.provider || - provider.label.toLowerCase().includes(targetPersona.provider ?? ""), - ); - return isGooseModelProvider ? "goose" : null; - }, - [catalogEntries, personas, providers, selectedAgentId], - ); - const canAutoCompactBeforeSend = useCallback( - (overridePersona?: { id: string; name?: string }) => { - const targetAgentId = resolveAutoCompactAgentId(overridePersona); - if ( - !sessionId || - !supportsContextAutoCompaction(targetAgentId) || - !isAutoCompactThresholdHydrated - ) { - return false; - } - - const liveRuntime = useChatStore - .getState() - .getSessionRuntime(stateSessionId); - return shouldAutoCompactContext( - liveRuntime.tokenState.accumulatedTotal, - liveRuntime.tokenState.contextLimit, - autoCompactThreshold, - ); - }, - [ - autoCompactThreshold, - isAutoCompactThresholdHydrated, - resolveAutoCompactAgentId, - sessionId, - stateSessionId, - ], - ); - const sendWithAutoCompact = useCallback( - ( - text: string, - overridePersona?: { id: string; name?: string }, - attachments?: ChatAttachmentDraft[], - sendOptions?: ChatSendOptions, - ) => { - if (!canAutoCompactBeforeSend(overridePersona)) { - if (sendOptions) { - void sendMessage(text, overridePersona, attachments, sendOptions); - } else { - void sendMessage(text, overridePersona, attachments); - } - return true; - } - - return (async () => { - const compactionResult = await compactConversation(overridePersona); - if (compactionResult !== "completed") { - return false; - } - - if (sendOptions) { - void sendMessage(text, overridePersona, attachments, sendOptions); - } else { - void sendMessage(text, overridePersona, attachments); - } - return true; - })(); - }, - [canAutoCompactBeforeSend, compactConversation, sendMessage], - ); const isLoadingHistory = useChatStore((s) => sessionId ? s.loadingSessionIds.has(sessionId) && @@ -553,7 +454,9 @@ export function useChatSessionController({ const queue = useMessageQueue( stateSessionId, sessionId ? chatState : "thinking", - sendWithAutoCompact, + (...args) => { + void sendMessage(...args); + }, ); const handleSend = useCallback( @@ -582,7 +485,12 @@ export function useChatSessionController({ return true; } - return sendWithAutoCompact(text, undefined, attachments, sendOptions); + if (sendOptions) { + void sendMessage(text, undefined, attachments, sendOptions); + } else { + void sendMessage(text, undefined, attachments); + } + return true; }, [ chatState, @@ -590,7 +498,7 @@ export function useChatSessionController({ queue, sessionId, selectedPersonaId, - sendWithAutoCompact, + sendMessage, ], ); @@ -598,24 +506,12 @@ export function useChatSessionController({ if (deferredSend.current && selectedPersona) { const { text, attachments, sendOptions, resolve } = deferredSend.current; deferredSend.current = null; - const sendResult = sendWithAutoCompact( - text, - undefined, - attachments, - sendOptions, - ); - if (sendResult instanceof Promise) { - void sendResult.then((accepted) => { - if (accepted === false) { - useChatStore.getState().setDraft(stateSessionId, text); - } - resolve?.(accepted !== false); - }); - return; - } - resolve?.(true); + const sendResult = sendOptions + ? sendMessage(text, undefined, attachments, sendOptions) + : sendMessage(text, undefined, attachments); + void sendResult.then(() => resolve?.(true)); } - }, [selectedPersona, sendWithAutoCompact, stateSessionId]); + }, [selectedPersona, sendMessage]); const handleCreatePersona = useCallback(() => { if (onCreatePersonaRequested) { @@ -812,7 +708,6 @@ export function useChatSessionController({ canCompactContext: supportsCompactionControls && messages.length > 0 && chatState === "idle", isCompactingContext, - supportsAutoCompactContext, supportsCompactionControls, isContextUsageReady: hasContextUsageSnapshot && resolvedTokenState.contextLimit > 0, diff --git a/ui/goose2/src/features/chat/hooks/useMessageQueue.ts b/ui/goose2/src/features/chat/hooks/useMessageQueue.ts index 85a3eea6..f46cf719 100644 --- a/ui/goose2/src/features/chat/hooks/useMessageQueue.ts +++ b/ui/goose2/src/features/chat/hooks/useMessageQueue.ts @@ -1,38 +1,9 @@ -import { useEffect, useCallback, useMemo, useRef } from "react"; +import { useEffect, useCallback } from "react"; import type { ChatState } from "@/shared/types/chat"; -import { isPromiseLike } from "@/shared/lib/isPromiseLike"; import type { ChatAttachmentDraft } from "@/shared/types/messages"; import { useChatStore } from "../stores/chatStore"; import type { ChatSendOptions } from "../types"; -const MAX_CONSECUTIVE_SEND_FAILURES = 2; - -function getQueuedMessageKey( - queuedMessage: { - text: string; - personaId?: string; - attachments?: ChatAttachmentDraft[]; - sendOptions?: ChatSendOptions; - } | null, -): string | null { - if (!queuedMessage) { - return null; - } - - return JSON.stringify({ - text: queuedMessage.text, - personaId: queuedMessage.personaId ?? null, - sendOptions: queuedMessage.sendOptions ?? null, - attachments: - queuedMessage.attachments?.map((attachment) => ({ - id: attachment.id, - kind: attachment.kind, - name: attachment.name, - path: "path" in attachment ? (attachment.path ?? null) : null, - })) ?? [], - }); -} - /** * Single-slot message queue that holds one pending message while the agent is * busy and auto-sends it when the chat transitions back to idle. @@ -49,111 +20,32 @@ export function useMessageQueue( overridePersona?: { id: string; name?: string }, attachments?: ChatAttachmentDraft[], sendOptions?: ChatSendOptions, - ) => boolean | Promise, + ) => void, ) { const queuedMessage = useChatStore( (s) => s.queuedMessageBySession[sessionId] ?? null, ); - const previousChatStateRef = useRef(chatState); - const idleCycleRef = useRef(0); - const lastAttemptRef = useRef<{ - key: string; - idleCycle: number; - } | null>(null); - const failureStateRef = useRef<{ - key: string; - count: number; - } | null>(null); - const queuedMessageKey = useMemo( - () => getQueuedMessageKey(queuedMessage), - [queuedMessage], - ); useEffect(() => { - if (queuedMessageKey !== lastAttemptRef.current?.key) { - lastAttemptRef.current = null; - } - if (queuedMessageKey !== failureStateRef.current?.key) { - failureStateRef.current = null; - } - }, [queuedMessageKey]); - - useEffect(() => { - if (chatState === "idle" && previousChatStateRef.current !== "idle") { - idleCycleRef.current += 1; - } - previousChatStateRef.current = chatState; - }, [chatState]); - - useEffect(() => { - const hasReachedRetryLimit = - failureStateRef.current?.key === queuedMessageKey && - failureStateRef.current.count >= MAX_CONSECUTIVE_SEND_FAILURES; - const alreadyAttemptedThisIdleCycle = - lastAttemptRef.current?.key === queuedMessageKey && - lastAttemptRef.current.idleCycle === idleCycleRef.current; - - if ( - chatState !== "idle" || - !queuedMessage || - !queuedMessageKey || - hasReachedRetryLimit || - alreadyAttemptedThisIdleCycle - ) { - return; - } - - lastAttemptRef.current = { - key: queuedMessageKey, - idleCycle: idleCycleRef.current, - }; - - const { text, personaId, attachments, sendOptions } = queuedMessage; - const sendResult = sendOptions - ? sendMessage( + if (chatState === "idle" && queuedMessage) { + const { text, personaId, attachments, sendOptions } = queuedMessage; + useChatStore.getState().dismissQueuedMessage(sessionId); + if (sendOptions) { + sendMessage( text, personaId ? { id: personaId } : undefined, attachments, sendOptions, - ) - : sendMessage( + ); + } else { + sendMessage( text, personaId ? { id: personaId } : undefined, attachments, ); - - const finalize = (accepted: boolean | undefined) => { - const latestQueuedMessage = - useChatStore.getState().queuedMessageBySession[sessionId] ?? null; - if (getQueuedMessageKey(latestQueuedMessage) !== queuedMessageKey) { - return; } - - if (accepted === false) { - const previousFailureCount = - failureStateRef.current?.key === queuedMessageKey - ? failureStateRef.current.count - : 0; - failureStateRef.current = { - key: queuedMessageKey, - count: previousFailureCount + 1, - }; - return; - } - - failureStateRef.current = null; - lastAttemptRef.current = null; - useChatStore.getState().dismissQueuedMessage(sessionId); - }; - - if (isPromiseLike(sendResult)) { - void sendResult - .then((accepted) => finalize(accepted)) - .catch(() => finalize(false)); - } else { - finalize(sendResult); } - }, [chatState, queuedMessage, queuedMessageKey, sendMessage, sessionId]); + }, [chatState, queuedMessage, sendMessage, sessionId]); const enqueue = useCallback( ( diff --git a/ui/goose2/src/features/chat/lib/__tests__/replaySanitizer.test.ts b/ui/goose2/src/features/chat/lib/__tests__/replaySanitizer.test.ts deleted file mode 100644 index 24ddbc4a..00000000 --- a/ui/goose2/src/features/chat/lib/__tests__/replaySanitizer.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { Message } from "@/shared/types/messages"; -import { sanitizeReplayMessages } from "../replaySanitizer"; - -function createTextMessage( - id: string, - role: Message["role"], - text: string, -): Message { - return { - id, - role, - created: 0, - content: [{ type: "text", text }], - metadata: { - userVisible: true, - agentVisible: role !== "system", - }, - }; -} - -describe("sanitizeReplayMessages", () => { - it("removes manual compaction control messages from replayed history", () => { - expect( - sanitizeReplayMessages([ - createTextMessage("user-1", "user", "Before compact"), - createTextMessage("compact-1", "user", "/compact"), - createTextMessage("compact-2", "user", "/compact/compact"), - createTextMessage("compact-4", "user", "/summarize"), - createTextMessage("assistant-1", "assistant", "After compact"), - ]), - ).toEqual([ - createTextMessage("user-1", "user", "Before compact"), - createTextMessage("assistant-1", "assistant", "After compact"), - ]); - }); - - it("keeps natural-language requests to compact the conversation", () => { - expect( - sanitizeReplayMessages([ - createTextMessage("user-1", "user", "Please compact this conversation"), - ]), - ).toEqual([ - createTextMessage("user-1", "user", "Please compact this conversation"), - ]); - }); - - it("keeps normal user messages that merely mention compact commands", () => { - expect( - sanitizeReplayMessages([ - createTextMessage( - "user-1", - "user", - "Can you explain what /compact does?", - ), - ]), - ).toEqual([ - createTextMessage( - "user-1", - "user", - "Can you explain what /compact does?", - ), - ]); - }); -}); diff --git a/ui/goose2/src/features/chat/lib/autoCompact.ts b/ui/goose2/src/features/chat/lib/autoCompact.ts index eb172ace..d4d6f0b6 100644 --- a/ui/goose2/src/features/chat/lib/autoCompact.ts +++ b/ui/goose2/src/features/chat/lib/autoCompact.ts @@ -61,34 +61,12 @@ export function autoCompactPercentToThreshold(value: number): number { return clampAutoCompactThresholdPercent(value) / 100; } -export function shouldAutoCompactContext( - usedTokens: number, - contextLimit: number, - threshold: number, -): boolean { - if (usedTokens <= 0 || contextLimit <= 0) { - return false; - } - - if (threshold <= 0 || threshold >= 1) { - return false; - } - - return usedTokens / contextLimit > threshold; -} - function supportsContextCompactionProvider( providerId: string | null | undefined, ): boolean { return providerId != null && CONTEXT_COMPACTION_PROVIDER_IDS.has(providerId); } -export function supportsContextAutoCompaction( - providerId: string | null | undefined, -): boolean { - return supportsContextCompactionProvider(providerId); -} - export function supportsContextCompactionControls( providerId: string | null | undefined, ): boolean { diff --git a/ui/goose2/src/features/chat/lib/replaySanitizer.ts b/ui/goose2/src/features/chat/lib/replaySanitizer.ts deleted file mode 100644 index 117e4334..00000000 --- a/ui/goose2/src/features/chat/lib/replaySanitizer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Message } from "@/shared/types/messages"; -import { getTextContent } from "@/shared/types/messages"; - -const MANUAL_COMPACT_TRIGGER = "/compact"; -const ALTERNATE_COMPACT_TRIGGERS = new Set(["/summarize"]); - -export function isManualCompactReplayArtifact(message: Message): boolean { - if (message.role !== "user") { - return false; - } - - const rawText = getTextContent(message).trim(); - if (!rawText) { - return false; - } - - const normalizedText = rawText.replace(/\s+/g, " ").trim().toLowerCase(); - if (ALTERNATE_COMPACT_TRIGGERS.has(normalizedText)) { - return true; - } - - const collapsedText = normalizedText.replace(/\s+/g, ""); - return ( - collapsedText.length > 0 && - collapsedText.replaceAll(MANUAL_COMPACT_TRIGGER, "").length === 0 - ); -} - -export function sanitizeReplayMessages(messages: Message[]): Message[] { - return messages.filter((message) => !isManualCompactReplayArtifact(message)); -}