revert client-side autocompaction (#9055)
This commit is contained in:
@@ -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: [
|
||||
{
|
||||
|
||||
-399
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user