revert client-side autocompaction (#9055)

This commit is contained in:
Jack Amadeo
2026-05-08 09:56:08 -04:00
committed by GitHub
parent 2db5e58ec7
commit 2c39717270
10 changed files with 36 additions and 814 deletions
+3 -7
View File
@@ -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);
@@ -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: [
{
@@ -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<boolean>)
| 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,
);
});
});
@@ -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",
});
});
});
+1 -2
View File
@@ -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 {
@@ -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,
@@ -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<boolean>,
) => 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<boolean>(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(
(
@@ -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?",
),
]);
});
});
@@ -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 {
@@ -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));
}