fix: ensure anthropic-auto-compact lock is always cleared (#232)
Fixes #200 ## Problem When executeCompact() recovery fails unexpectedly or gets interrupted, the compactionInProgress lock is never cleared, permanently blocking both auto-compact AND manual /compact for the session. ## Root Cause - No try/finally around lock acquisition (line 261) - Silent blocking when lock held - no user feedback - Lock cleanup scattered across 7 manual deletion points - Any unexpected exception bypasses cleanup, leaving lock stuck forever ## Solution 1. **Try/Finally Lock Guarantee**: Wrapped entire executeCompact body in try/finally block to guarantee lock cleanup, following the pattern used in preemptive-compaction hook 2. **User Feedback**: Added toast notification when compact attempt is blocked by existing lock, replacing silent failure with clear warning 3. **Removed Redundancy**: Removed 6 redundant manual lock deletions (kept only clearSessionState and finally block) ## Testing Evidence ✅ 10/10 comprehensive tests pass ✅ Lock cleared on successful completion ✅ Lock cleared when summarize throws ✅ Lock cleared when revert throws ✅ Lock cleared when fixEmptyMessages executes ✅ Lock cleared when truncation is sufficient ✅ Lock cleared after max recovery attempts ✅ Lock cleared when toast fails ✅ Lock cleared when prompt_async throws ✅ Toast shown when lock already held ✅ TypeScript type check passes with zero errors ## Files Changed - executor.ts: Added try/finally, toast notification, removed 6 redundant deletions - executor.test.ts: New comprehensive test suite (10 tests, 13 assertions) ## Impact - Severity: High → Fixed - User Experience: No more stuck sessions requiring restart - Behavior: Identical except lock now guaranteed to clear Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
260
src/hooks/anthropic-auto-compact/executor.test.ts
Normal file
260
src/hooks/anthropic-auto-compact/executor.test.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||||
|
import { executeCompact } from "./executor"
|
||||||
|
import type { AutoCompactState } from "./types"
|
||||||
|
|
||||||
|
describe("executeCompact lock management", () => {
|
||||||
|
let autoCompactState: AutoCompactState
|
||||||
|
let mockClient: any
|
||||||
|
const sessionID = "test-session-123"
|
||||||
|
const directory = "/test/dir"
|
||||||
|
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// #given: Fresh state for each test
|
||||||
|
autoCompactState = {
|
||||||
|
pendingCompact: new Set<string>(),
|
||||||
|
errorDataBySession: new Map(),
|
||||||
|
retryStateBySession: new Map(),
|
||||||
|
fallbackStateBySession: new Map(),
|
||||||
|
truncateStateBySession: new Map(),
|
||||||
|
emptyContentAttemptBySession: new Map(),
|
||||||
|
compactionInProgress: new Set<string>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
mockClient = {
|
||||||
|
session: {
|
||||||
|
messages: mock(() => Promise.resolve({ data: [] })),
|
||||||
|
summarize: mock(() => Promise.resolve()),
|
||||||
|
revert: mock(() => Promise.resolve()),
|
||||||
|
prompt_async: mock(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
tui: {
|
||||||
|
showToast: mock(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears lock on successful summarize completion", async () => {
|
||||||
|
// #given: Valid session with providerID/modelID
|
||||||
|
autoCompactState.errorDataBySession.set(sessionID, {
|
||||||
|
errorType: "token_limit",
|
||||||
|
currentTokens: 100000,
|
||||||
|
maxTokens: 200000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when: Execute compaction successfully
|
||||||
|
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||||
|
|
||||||
|
// #then: Lock should be cleared
|
||||||
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears lock when summarize throws exception", async () => {
|
||||||
|
// #given: Summarize will fail
|
||||||
|
mockClient.session.summarize = mock(() =>
|
||||||
|
Promise.reject(new Error("Network timeout")),
|
||||||
|
)
|
||||||
|
autoCompactState.errorDataBySession.set(sessionID, {
|
||||||
|
errorType: "token_limit",
|
||||||
|
currentTokens: 100000,
|
||||||
|
maxTokens: 200000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when: Execute compaction
|
||||||
|
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||||
|
|
||||||
|
// #then: Lock should still be cleared despite exception
|
||||||
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears lock when revert throws exception", async () => {
|
||||||
|
// #given: Force revert path by exhausting retry attempts and making revert fail
|
||||||
|
mockClient.session.revert = mock(() =>
|
||||||
|
Promise.reject(new Error("Revert failed")),
|
||||||
|
)
|
||||||
|
mockClient.session.messages = mock(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: [
|
||||||
|
{ info: { id: "msg1", role: "user" } },
|
||||||
|
{ info: { id: "msg2", role: "assistant" } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exhaust retry attempts
|
||||||
|
autoCompactState.retryStateBySession.set(sessionID, {
|
||||||
|
attempt: 5,
|
||||||
|
lastAttemptTime: Date.now(),
|
||||||
|
})
|
||||||
|
autoCompactState.errorDataBySession.set(sessionID, {
|
||||||
|
errorType: "token_limit",
|
||||||
|
currentTokens: 100000,
|
||||||
|
maxTokens: 200000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when: Execute compaction
|
||||||
|
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||||
|
|
||||||
|
// #then: Lock cleared even though revert failed
|
||||||
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows toast when lock already held", async () => {
|
||||||
|
// #given: Lock already held
|
||||||
|
autoCompactState.compactionInProgress.add(sessionID)
|
||||||
|
|
||||||
|
// #when: Try to execute compaction
|
||||||
|
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||||
|
|
||||||
|
// #then: Toast should be shown with warning message
|
||||||
|
expect(mockClient.tui.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.objectContaining({
|
||||||
|
title: "Compact In Progress",
|
||||||
|
message: expect.stringContaining("Recovery already running"),
|
||||||
|
variant: "warning",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// #then: compactionInProgress should still have the lock
|
||||||
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears lock when fixEmptyMessages path executes", async () => {
|
||||||
|
// #given: Empty content error scenario
|
||||||
|
autoCompactState.errorDataBySession.set(sessionID, {
|
||||||
|
errorType: "non-empty content required",
|
||||||
|
messageIndex: 0,
|
||||||
|
currentTokens: 100000,
|
||||||
|
maxTokens: 200000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when: Execute compaction (fixEmptyMessages will be called)
|
||||||
|
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||||
|
|
||||||
|
// #then: Lock should be cleared
|
||||||
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears lock when truncation is sufficient", async () => {
|
||||||
|
// #given: Aggressive truncation scenario with sufficient truncation
|
||||||
|
// This test verifies the early return path in aggressive truncation
|
||||||
|
autoCompactState.errorDataBySession.set(sessionID, {
|
||||||
|
errorType: "token_limit",
|
||||||
|
currentTokens: 250000,
|
||||||
|
maxTokens: 200000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const experimental = {
|
||||||
|
truncate_all_tool_outputs: false,
|
||||||
|
aggressive_truncation: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// #when: Execute compaction with experimental flag
|
||||||
|
await executeCompact(
|
||||||
|
sessionID,
|
||||||
|
msg,
|
||||||
|
autoCompactState,
|
||||||
|
mockClient,
|
||||||
|
directory,
|
||||||
|
experimental,
|
||||||
|
)
|
||||||
|
|
||||||
|
// #then: Lock should be cleared even on early return
|
||||||
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prevents concurrent compaction attempts", async () => {
|
||||||
|
// #given: Lock already held (simpler test)
|
||||||
|
autoCompactState.compactionInProgress.add(sessionID)
|
||||||
|
|
||||||
|
// #when: Try to execute compaction while lock is held
|
||||||
|
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||||
|
|
||||||
|
// #then: Toast should be shown
|
||||||
|
const toastCalls = (mockClient.tui.showToast as any).mock.calls
|
||||||
|
const blockedToast = toastCalls.find(
|
||||||
|
(call: any) => call[0]?.body?.title === "Compact In Progress",
|
||||||
|
)
|
||||||
|
expect(blockedToast).toBeDefined()
|
||||||
|
|
||||||
|
// #then: Lock should still be held (not cleared by blocked attempt)
|
||||||
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears lock after max recovery attempts exhausted", async () => {
|
||||||
|
// #given: All retry/revert attempts exhausted
|
||||||
|
mockClient.session.messages = mock(() => Promise.resolve({ data: [] }))
|
||||||
|
|
||||||
|
// Max out all attempts
|
||||||
|
autoCompactState.retryStateBySession.set(sessionID, {
|
||||||
|
attempt: 5,
|
||||||
|
lastAttemptTime: Date.now(),
|
||||||
|
})
|
||||||
|
autoCompactState.fallbackStateBySession.set(sessionID, {
|
||||||
|
revertAttempt: 5,
|
||||||
|
})
|
||||||
|
autoCompactState.truncateStateBySession.set(sessionID, {
|
||||||
|
truncateAttempt: 5,
|
||||||
|
})
|
||||||
|
autoCompactState.errorDataBySession.set(sessionID, {
|
||||||
|
errorType: "token_limit",
|
||||||
|
currentTokens: 100000,
|
||||||
|
maxTokens: 200000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when: Execute compaction
|
||||||
|
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||||
|
|
||||||
|
// #then: Should show failure toast
|
||||||
|
const toastCalls = (mockClient.tui.showToast as any).mock.calls
|
||||||
|
const failureToast = toastCalls.find(
|
||||||
|
(call: any) => call[0]?.body?.title === "Auto Compact Failed",
|
||||||
|
)
|
||||||
|
expect(failureToast).toBeDefined()
|
||||||
|
|
||||||
|
// #then: Lock should still be cleared
|
||||||
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears lock when client.tui.showToast throws", async () => {
|
||||||
|
// #given: Toast will fail (this should never happen but testing robustness)
|
||||||
|
mockClient.tui.showToast = mock(() =>
|
||||||
|
Promise.reject(new Error("Toast failed")),
|
||||||
|
)
|
||||||
|
autoCompactState.errorDataBySession.set(sessionID, {
|
||||||
|
errorType: "token_limit",
|
||||||
|
currentTokens: 100000,
|
||||||
|
maxTokens: 200000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when: Execute compaction
|
||||||
|
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||||
|
|
||||||
|
// #then: Lock should be cleared even if toast fails
|
||||||
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears lock when prompt_async in continuation throws", async () => {
|
||||||
|
// #given: prompt_async will fail during continuation
|
||||||
|
mockClient.session.prompt_async = mock(() =>
|
||||||
|
Promise.reject(new Error("Prompt failed")),
|
||||||
|
)
|
||||||
|
autoCompactState.errorDataBySession.set(sessionID, {
|
||||||
|
errorType: "token_limit",
|
||||||
|
currentTokens: 100000,
|
||||||
|
maxTokens: 200000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when: Execute compaction
|
||||||
|
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||||
|
|
||||||
|
// Wait for setTimeout callback
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||||
|
|
||||||
|
// #then: Lock should be cleared
|
||||||
|
// The continuation happens in setTimeout, but lock is cleared in finally before that
|
||||||
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,206 +1,238 @@
|
|||||||
import type { AutoCompactState, FallbackState, RetryState, TruncateState } from "./types"
|
import type {
|
||||||
import type { ExperimentalConfig } from "../../config"
|
AutoCompactState,
|
||||||
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"
|
FallbackState,
|
||||||
import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage"
|
RetryState,
|
||||||
|
TruncateState,
|
||||||
|
} from "./types";
|
||||||
|
import type { ExperimentalConfig } from "../../config";
|
||||||
|
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
|
||||||
|
import {
|
||||||
|
findLargestToolResult,
|
||||||
|
truncateToolResult,
|
||||||
|
truncateUntilTargetTokens,
|
||||||
|
} from "./storage";
|
||||||
import {
|
import {
|
||||||
findEmptyMessages,
|
findEmptyMessages,
|
||||||
findEmptyMessageByIndex,
|
findEmptyMessageByIndex,
|
||||||
injectTextPart,
|
injectTextPart,
|
||||||
replaceEmptyTextParts,
|
replaceEmptyTextParts,
|
||||||
} from "../session-recovery/storage"
|
} from "../session-recovery/storage";
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger";
|
||||||
|
|
||||||
type Client = {
|
type Client = {
|
||||||
session: {
|
session: {
|
||||||
messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise<unknown>
|
messages: (opts: {
|
||||||
|
path: { id: string };
|
||||||
|
query?: { directory?: string };
|
||||||
|
}) => Promise<unknown>;
|
||||||
summarize: (opts: {
|
summarize: (opts: {
|
||||||
path: { id: string }
|
path: { id: string };
|
||||||
body: { providerID: string; modelID: string }
|
body: { providerID: string; modelID: string };
|
||||||
query: { directory: string }
|
query: { directory: string };
|
||||||
}) => Promise<unknown>
|
}) => Promise<unknown>;
|
||||||
revert: (opts: {
|
revert: (opts: {
|
||||||
path: { id: string }
|
path: { id: string };
|
||||||
body: { messageID: string; partID?: string }
|
body: { messageID: string; partID?: string };
|
||||||
query: { directory: string }
|
query: { directory: string };
|
||||||
}) => Promise<unknown>
|
}) => Promise<unknown>;
|
||||||
prompt_async: (opts: {
|
prompt_async: (opts: {
|
||||||
path: { sessionID: string }
|
path: { sessionID: string };
|
||||||
body: { parts: Array<{ type: string; text: string }> }
|
body: { parts: Array<{ type: string; text: string }> };
|
||||||
query: { directory: string }
|
query: { directory: string };
|
||||||
}) => Promise<unknown>
|
}) => Promise<unknown>;
|
||||||
}
|
};
|
||||||
tui: {
|
tui: {
|
||||||
showToast: (opts: {
|
showToast: (opts: {
|
||||||
body: { title: string; message: string; variant: string; duration: number }
|
body: {
|
||||||
}) => Promise<unknown>
|
title: string;
|
||||||
}
|
message: string;
|
||||||
}
|
variant: string;
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
function getOrCreateRetryState(
|
function getOrCreateRetryState(
|
||||||
autoCompactState: AutoCompactState,
|
autoCompactState: AutoCompactState,
|
||||||
sessionID: string
|
sessionID: string,
|
||||||
): RetryState {
|
): RetryState {
|
||||||
let state = autoCompactState.retryStateBySession.get(sessionID)
|
let state = autoCompactState.retryStateBySession.get(sessionID);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = { attempt: 0, lastAttemptTime: 0 }
|
state = { attempt: 0, lastAttemptTime: 0 };
|
||||||
autoCompactState.retryStateBySession.set(sessionID, state)
|
autoCompactState.retryStateBySession.set(sessionID, state);
|
||||||
}
|
}
|
||||||
return state
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrCreateFallbackState(
|
function getOrCreateFallbackState(
|
||||||
autoCompactState: AutoCompactState,
|
autoCompactState: AutoCompactState,
|
||||||
sessionID: string
|
sessionID: string,
|
||||||
): FallbackState {
|
): FallbackState {
|
||||||
let state = autoCompactState.fallbackStateBySession.get(sessionID)
|
let state = autoCompactState.fallbackStateBySession.get(sessionID);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = { revertAttempt: 0 }
|
state = { revertAttempt: 0 };
|
||||||
autoCompactState.fallbackStateBySession.set(sessionID, state)
|
autoCompactState.fallbackStateBySession.set(sessionID, state);
|
||||||
}
|
}
|
||||||
return state
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrCreateTruncateState(
|
function getOrCreateTruncateState(
|
||||||
autoCompactState: AutoCompactState,
|
autoCompactState: AutoCompactState,
|
||||||
sessionID: string
|
sessionID: string,
|
||||||
): TruncateState {
|
): TruncateState {
|
||||||
let state = autoCompactState.truncateStateBySession.get(sessionID)
|
let state = autoCompactState.truncateStateBySession.get(sessionID);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = { truncateAttempt: 0 }
|
state = { truncateAttempt: 0 };
|
||||||
autoCompactState.truncateStateBySession.set(sessionID, state)
|
autoCompactState.truncateStateBySession.set(sessionID, state);
|
||||||
}
|
}
|
||||||
return state
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLastMessagePair(
|
async function getLastMessagePair(
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
client: Client,
|
client: Client,
|
||||||
directory: string
|
directory: string,
|
||||||
): Promise<{ userMessageID: string; assistantMessageID?: string } | null> {
|
): Promise<{ userMessageID: string; assistantMessageID?: string } | null> {
|
||||||
try {
|
try {
|
||||||
const resp = await client.session.messages({
|
const resp = await client.session.messages({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
});
|
||||||
|
|
||||||
const data = (resp as { data?: unknown[] }).data
|
const data = (resp as { data?: unknown[] }).data;
|
||||||
if (!Array.isArray(data) || data.length < FALLBACK_CONFIG.minMessagesRequired) {
|
if (
|
||||||
return null
|
!Array.isArray(data) ||
|
||||||
|
data.length < FALLBACK_CONFIG.minMessagesRequired
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reversed = [...data].reverse()
|
const reversed = [...data].reverse();
|
||||||
|
|
||||||
const lastAssistant = reversed.find((m) => {
|
const lastAssistant = reversed.find((m) => {
|
||||||
const msg = m as Record<string, unknown>
|
const msg = m as Record<string, unknown>;
|
||||||
const info = msg.info as Record<string, unknown> | undefined
|
const info = msg.info as Record<string, unknown> | undefined;
|
||||||
return info?.role === "assistant"
|
return info?.role === "assistant";
|
||||||
})
|
});
|
||||||
|
|
||||||
const lastUser = reversed.find((m) => {
|
const lastUser = reversed.find((m) => {
|
||||||
const msg = m as Record<string, unknown>
|
const msg = m as Record<string, unknown>;
|
||||||
const info = msg.info as Record<string, unknown> | undefined
|
const info = msg.info as Record<string, unknown> | undefined;
|
||||||
return info?.role === "user"
|
return info?.role === "user";
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!lastUser) return null
|
if (!lastUser) return null;
|
||||||
const userInfo = (lastUser as { info?: Record<string, unknown> }).info
|
const userInfo = (lastUser as { info?: Record<string, unknown> }).info;
|
||||||
const userMessageID = userInfo?.id as string | undefined
|
const userMessageID = userInfo?.id as string | undefined;
|
||||||
if (!userMessageID) return null
|
if (!userMessageID) return null;
|
||||||
|
|
||||||
let assistantMessageID: string | undefined
|
let assistantMessageID: string | undefined;
|
||||||
if (lastAssistant) {
|
if (lastAssistant) {
|
||||||
const assistantInfo = (lastAssistant as { info?: Record<string, unknown> }).info
|
const assistantInfo = (
|
||||||
assistantMessageID = assistantInfo?.id as string | undefined
|
lastAssistant as { info?: Record<string, unknown> }
|
||||||
|
).info;
|
||||||
|
assistantMessageID = assistantInfo?.id as string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { userMessageID, assistantMessageID }
|
return { userMessageID, assistantMessageID };
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes}B`
|
if (bytes < 1024) return `${bytes}B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLastAssistant(
|
export async function getLastAssistant(
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
client: any,
|
client: any,
|
||||||
directory: string
|
directory: string,
|
||||||
): Promise<Record<string, unknown> | null> {
|
): Promise<Record<string, unknown> | null> {
|
||||||
try {
|
try {
|
||||||
const resp = await (client as Client).session.messages({
|
const resp = await (client as Client).session.messages({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
});
|
||||||
|
|
||||||
const data = (resp as { data?: unknown[] }).data
|
const data = (resp as { data?: unknown[] }).data;
|
||||||
if (!Array.isArray(data)) return null
|
if (!Array.isArray(data)) return null;
|
||||||
|
|
||||||
const reversed = [...data].reverse()
|
const reversed = [...data].reverse();
|
||||||
const last = reversed.find((m) => {
|
const last = reversed.find((m) => {
|
||||||
const msg = m as Record<string, unknown>
|
const msg = m as Record<string, unknown>;
|
||||||
const info = msg.info as Record<string, unknown> | undefined
|
const info = msg.info as Record<string, unknown> | undefined;
|
||||||
return info?.role === "assistant"
|
return info?.role === "assistant";
|
||||||
})
|
});
|
||||||
if (!last) return null
|
if (!last) return null;
|
||||||
return (last as { info?: Record<string, unknown> }).info ?? null
|
return (last as { info?: Record<string, unknown> }).info ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSessionState(autoCompactState: AutoCompactState, sessionID: string): void {
|
function clearSessionState(
|
||||||
autoCompactState.pendingCompact.delete(sessionID)
|
autoCompactState: AutoCompactState,
|
||||||
autoCompactState.errorDataBySession.delete(sessionID)
|
sessionID: string,
|
||||||
autoCompactState.retryStateBySession.delete(sessionID)
|
): void {
|
||||||
autoCompactState.fallbackStateBySession.delete(sessionID)
|
autoCompactState.pendingCompact.delete(sessionID);
|
||||||
autoCompactState.truncateStateBySession.delete(sessionID)
|
autoCompactState.errorDataBySession.delete(sessionID);
|
||||||
autoCompactState.emptyContentAttemptBySession.delete(sessionID)
|
autoCompactState.retryStateBySession.delete(sessionID);
|
||||||
autoCompactState.compactionInProgress.delete(sessionID)
|
autoCompactState.fallbackStateBySession.delete(sessionID);
|
||||||
|
autoCompactState.truncateStateBySession.delete(sessionID);
|
||||||
|
autoCompactState.emptyContentAttemptBySession.delete(sessionID);
|
||||||
|
autoCompactState.compactionInProgress.delete(sessionID);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrCreateEmptyContentAttempt(
|
function getOrCreateEmptyContentAttempt(
|
||||||
autoCompactState: AutoCompactState,
|
autoCompactState: AutoCompactState,
|
||||||
sessionID: string
|
sessionID: string,
|
||||||
): number {
|
): number {
|
||||||
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0
|
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fixEmptyMessages(
|
async function fixEmptyMessages(
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
autoCompactState: AutoCompactState,
|
autoCompactState: AutoCompactState,
|
||||||
client: Client,
|
client: Client,
|
||||||
messageIndex?: number
|
messageIndex?: number,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
|
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID);
|
||||||
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1)
|
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1);
|
||||||
|
|
||||||
let fixed = false
|
let fixed = false;
|
||||||
const fixedMessageIds: string[] = []
|
const fixedMessageIds: string[] = [];
|
||||||
|
|
||||||
if (messageIndex !== undefined) {
|
if (messageIndex !== undefined) {
|
||||||
const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex)
|
const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex);
|
||||||
if (targetMessageId) {
|
if (targetMessageId) {
|
||||||
const replaced = replaceEmptyTextParts(targetMessageId, "[user interrupted]")
|
const replaced = replaceEmptyTextParts(
|
||||||
|
targetMessageId,
|
||||||
|
"[user interrupted]",
|
||||||
|
);
|
||||||
if (replaced) {
|
if (replaced) {
|
||||||
fixed = true
|
fixed = true;
|
||||||
fixedMessageIds.push(targetMessageId)
|
fixedMessageIds.push(targetMessageId);
|
||||||
} else {
|
} else {
|
||||||
const injected = injectTextPart(sessionID, targetMessageId, "[user interrupted]")
|
const injected = injectTextPart(
|
||||||
|
sessionID,
|
||||||
|
targetMessageId,
|
||||||
|
"[user interrupted]",
|
||||||
|
);
|
||||||
if (injected) {
|
if (injected) {
|
||||||
fixed = true
|
fixed = true;
|
||||||
fixedMessageIds.push(targetMessageId)
|
fixedMessageIds.push(targetMessageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fixed) {
|
if (!fixed) {
|
||||||
const emptyMessageIds = findEmptyMessages(sessionID)
|
const emptyMessageIds = findEmptyMessages(sessionID);
|
||||||
if (emptyMessageIds.length === 0) {
|
if (emptyMessageIds.length === 0) {
|
||||||
await client.tui
|
await client.tui
|
||||||
.showToast({
|
.showToast({
|
||||||
@@ -211,20 +243,24 @@ async function fixEmptyMessages(
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const messageID of emptyMessageIds) {
|
for (const messageID of emptyMessageIds) {
|
||||||
const replaced = replaceEmptyTextParts(messageID, "[user interrupted]")
|
const replaced = replaceEmptyTextParts(messageID, "[user interrupted]");
|
||||||
if (replaced) {
|
if (replaced) {
|
||||||
fixed = true
|
fixed = true;
|
||||||
fixedMessageIds.push(messageID)
|
fixedMessageIds.push(messageID);
|
||||||
} else {
|
} else {
|
||||||
const injected = injectTextPart(sessionID, messageID, "[user interrupted]")
|
const injected = injectTextPart(
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
"[user interrupted]",
|
||||||
|
);
|
||||||
if (injected) {
|
if (injected) {
|
||||||
fixed = true
|
fixed = true;
|
||||||
fixedMessageIds.push(messageID)
|
fixedMessageIds.push(messageID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,10 +276,10 @@ async function fixEmptyMessages(
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return fixed
|
return fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeCompact(
|
export async function executeCompact(
|
||||||
@@ -253,15 +289,27 @@ export async function executeCompact(
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
client: any,
|
client: any,
|
||||||
directory: string,
|
directory: string,
|
||||||
experimental?: ExperimentalConfig
|
experimental?: ExperimentalConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||||
return
|
await (client as Client).tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Compact In Progress",
|
||||||
|
message:
|
||||||
|
"Recovery already running. Please wait or start new session if stuck.",
|
||||||
|
variant: "warning",
|
||||||
|
duration: 5000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
autoCompactState.compactionInProgress.add(sessionID)
|
autoCompactState.compactionInProgress.add(sessionID);
|
||||||
|
|
||||||
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
try {
|
||||||
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID)
|
const errorData = autoCompactState.errorDataBySession.get(sessionID);
|
||||||
|
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
experimental?.aggressive_truncation &&
|
experimental?.aggressive_truncation &&
|
||||||
@@ -274,50 +322,52 @@ export async function executeCompact(
|
|||||||
currentTokens: errorData.currentTokens,
|
currentTokens: errorData.currentTokens,
|
||||||
maxTokens: errorData.maxTokens,
|
maxTokens: errorData.maxTokens,
|
||||||
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
|
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
|
||||||
})
|
});
|
||||||
|
|
||||||
const aggressiveResult = truncateUntilTargetTokens(
|
const aggressiveResult = truncateUntilTargetTokens(
|
||||||
sessionID,
|
sessionID,
|
||||||
errorData.currentTokens,
|
errorData.currentTokens,
|
||||||
errorData.maxTokens,
|
errorData.maxTokens,
|
||||||
TRUNCATE_CONFIG.targetTokenRatio,
|
TRUNCATE_CONFIG.targetTokenRatio,
|
||||||
TRUNCATE_CONFIG.charsPerToken
|
TRUNCATE_CONFIG.charsPerToken,
|
||||||
)
|
);
|
||||||
|
|
||||||
if (aggressiveResult.truncatedCount > 0) {
|
if (aggressiveResult.truncatedCount > 0) {
|
||||||
truncateState.truncateAttempt += aggressiveResult.truncatedCount
|
truncateState.truncateAttempt += aggressiveResult.truncatedCount;
|
||||||
|
|
||||||
const toolNames = aggressiveResult.truncatedTools.map((t) => t.toolName).join(", ")
|
const toolNames = aggressiveResult.truncatedTools
|
||||||
|
.map((t) => t.toolName)
|
||||||
|
.join(", ");
|
||||||
const statusMsg = aggressiveResult.sufficient
|
const statusMsg = aggressiveResult.sufficient
|
||||||
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
|
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
|
||||||
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`
|
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`;
|
||||||
|
|
||||||
await (client as Client).tui
|
await (client as Client).tui
|
||||||
.showToast({
|
.showToast({
|
||||||
body: {
|
body: {
|
||||||
title: aggressiveResult.sufficient ? "Aggressive Truncation" : "Partial Truncation",
|
title: aggressiveResult.sufficient
|
||||||
|
? "Aggressive Truncation"
|
||||||
|
: "Partial Truncation",
|
||||||
message: `${statusMsg}: ${toolNames}`,
|
message: `${statusMsg}: ${toolNames}`,
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
|
|
||||||
log("[auto-compact] aggressive truncation completed", aggressiveResult)
|
log("[auto-compact] aggressive truncation completed", aggressiveResult);
|
||||||
|
|
||||||
if (aggressiveResult.sufficient) {
|
if (aggressiveResult.sufficient) {
|
||||||
autoCompactState.compactionInProgress.delete(sessionID)
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await (client as Client).session.prompt_async({
|
await (client as Client).session.prompt_async({
|
||||||
path: { sessionID },
|
path: { sessionID },
|
||||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}, 500)
|
}, 500);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await (client as Client).tui
|
await (client as Client).tui
|
||||||
@@ -329,21 +379,24 @@ export async function executeCompact(
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let skipSummarize = false
|
let skipSummarize = false;
|
||||||
|
|
||||||
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
|
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
|
||||||
const largest = findLargestToolResult(sessionID)
|
const largest = findLargestToolResult(sessionID);
|
||||||
|
|
||||||
if (largest && largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate) {
|
if (
|
||||||
const result = truncateToolResult(largest.partPath)
|
largest &&
|
||||||
|
largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate
|
||||||
|
) {
|
||||||
|
const result = truncateToolResult(largest.partPath);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
truncateState.truncateAttempt++
|
truncateState.truncateAttempt++;
|
||||||
truncateState.lastTruncatedPartId = largest.partId
|
truncateState.lastTruncatedPartId = largest.partId;
|
||||||
|
|
||||||
await (client as Client).tui
|
await (client as Client).tui
|
||||||
.showToast({
|
.showToast({
|
||||||
@@ -354,9 +407,7 @@ export async function executeCompact(
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
|
|
||||||
autoCompactState.compactionInProgress.delete(sessionID)
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -364,13 +415,17 @@ export async function executeCompact(
|
|||||||
path: { sessionID },
|
path: { sessionID },
|
||||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}, 500)
|
}, 500);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
} else if (errorData?.currentTokens && errorData?.maxTokens && errorData.currentTokens > errorData.maxTokens) {
|
} else if (
|
||||||
skipSummarize = true
|
errorData?.currentTokens &&
|
||||||
|
errorData?.maxTokens &&
|
||||||
|
errorData.currentTokens > errorData.maxTokens
|
||||||
|
) {
|
||||||
|
skipSummarize = true;
|
||||||
await (client as Client).tui
|
await (client as Client).tui
|
||||||
.showToast({
|
.showToast({
|
||||||
body: {
|
body: {
|
||||||
@@ -380,7 +435,7 @@ export async function executeCompact(
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
} else if (!errorData?.currentTokens) {
|
} else if (!errorData?.currentTokens) {
|
||||||
await (client as Client).tui
|
await (client as Client).tui
|
||||||
.showToast({
|
.showToast({
|
||||||
@@ -391,56 +446,65 @@ export async function executeCompact(
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID)
|
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
|
||||||
|
|
||||||
if (errorData?.errorType?.includes("non-empty content")) {
|
if (errorData?.errorType?.includes("non-empty content")) {
|
||||||
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
|
const attempt = getOrCreateEmptyContentAttempt(
|
||||||
|
autoCompactState,
|
||||||
|
sessionID,
|
||||||
|
);
|
||||||
if (attempt < 3) {
|
if (attempt < 3) {
|
||||||
const fixed = await fixEmptyMessages(
|
const fixed = await fixEmptyMessages(
|
||||||
sessionID,
|
sessionID,
|
||||||
autoCompactState,
|
autoCompactState,
|
||||||
client as Client,
|
client as Client,
|
||||||
errorData.messageIndex
|
errorData.messageIndex,
|
||||||
)
|
);
|
||||||
if (fixed) {
|
if (fixed) {
|
||||||
autoCompactState.compactionInProgress.delete(sessionID)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
|
executeCompact(
|
||||||
}, 500)
|
sessionID,
|
||||||
return
|
msg,
|
||||||
|
autoCompactState,
|
||||||
|
client,
|
||||||
|
directory,
|
||||||
|
experimental,
|
||||||
|
);
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await (client as Client).tui
|
await (client as Client).tui
|
||||||
.showToast({
|
.showToast({
|
||||||
body: {
|
body: {
|
||||||
title: "Recovery Failed",
|
title: "Recovery Failed",
|
||||||
message: "Max recovery attempts (3) reached for empty content error. Please start a new session.",
|
message:
|
||||||
|
"Max recovery attempts (3) reached for empty content error. Please start a new session.",
|
||||||
variant: "error",
|
variant: "error",
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
autoCompactState.compactionInProgress.delete(sessionID)
|
return;
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() - retryState.lastAttemptTime > 300000) {
|
if (Date.now() - retryState.lastAttemptTime > 300000) {
|
||||||
retryState.attempt = 0
|
retryState.attempt = 0;
|
||||||
autoCompactState.fallbackStateBySession.delete(sessionID)
|
autoCompactState.fallbackStateBySession.delete(sessionID);
|
||||||
autoCompactState.truncateStateBySession.delete(sessionID)
|
autoCompactState.truncateStateBySession.delete(sessionID);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
||||||
retryState.attempt++
|
retryState.attempt++;
|
||||||
retryState.lastAttemptTime = Date.now()
|
retryState.lastAttemptTime = Date.now();
|
||||||
|
|
||||||
const providerID = msg.providerID as string | undefined
|
const providerID = msg.providerID as string | undefined;
|
||||||
const modelID = msg.modelID as string | undefined
|
const modelID = msg.modelID as string | undefined;
|
||||||
|
|
||||||
if (providerID && modelID) {
|
if (providerID && modelID) {
|
||||||
try {
|
try {
|
||||||
@@ -453,15 +517,13 @@ export async function executeCompact(
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
|
|
||||||
await (client as Client).session.summarize({
|
await (client as Client).session.summarize({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: { providerID, modelID },
|
body: { providerID, modelID },
|
||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
});
|
||||||
|
|
||||||
autoCompactState.compactionInProgress.delete(sessionID)
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -469,20 +531,27 @@ export async function executeCompact(
|
|||||||
path: { sessionID },
|
path: { sessionID },
|
||||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}, 500)
|
}, 500);
|
||||||
return
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
autoCompactState.compactionInProgress.delete(sessionID)
|
const delay =
|
||||||
|
RETRY_CONFIG.initialDelayMs *
|
||||||
const delay = RETRY_CONFIG.initialDelayMs * Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1)
|
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1);
|
||||||
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs)
|
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
|
executeCompact(
|
||||||
}, cappedDelay)
|
sessionID,
|
||||||
return
|
msg,
|
||||||
|
autoCompactState,
|
||||||
|
client,
|
||||||
|
directory,
|
||||||
|
experimental,
|
||||||
|
);
|
||||||
|
}, cappedDelay);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await (client as Client).tui
|
await (client as Client).tui
|
||||||
@@ -494,14 +563,18 @@ export async function executeCompact(
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID)
|
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID);
|
||||||
|
|
||||||
if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) {
|
if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) {
|
||||||
const pair = await getLastMessagePair(sessionID, client as Client, directory)
|
const pair = await getLastMessagePair(
|
||||||
|
sessionID,
|
||||||
|
client as Client,
|
||||||
|
directory,
|
||||||
|
);
|
||||||
|
|
||||||
if (pair) {
|
if (pair) {
|
||||||
try {
|
try {
|
||||||
@@ -514,27 +587,27 @@ export async function executeCompact(
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
|
|
||||||
if (pair.assistantMessageID) {
|
if (pair.assistantMessageID) {
|
||||||
await (client as Client).session.revert({
|
await (client as Client).session.revert({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: { messageID: pair.assistantMessageID },
|
body: { messageID: pair.assistantMessageID },
|
||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await (client as Client).session.revert({
|
await (client as Client).session.revert({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: { messageID: pair.userMessageID },
|
body: { messageID: pair.userMessageID },
|
||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
});
|
||||||
|
|
||||||
fallbackState.revertAttempt++
|
fallbackState.revertAttempt++;
|
||||||
fallbackState.lastRevertedMessageID = pair.userMessageID
|
fallbackState.lastRevertedMessageID = pair.userMessageID;
|
||||||
|
|
||||||
// Clear all state after successful revert - don't recurse
|
// Clear all state after successful revert - don't recurse
|
||||||
clearSessionState(autoCompactState, sessionID)
|
clearSessionState(autoCompactState, sessionID);
|
||||||
|
|
||||||
// Send "Continue" prompt to resume session
|
// Send "Continue" prompt to resume session
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
@@ -543,10 +616,10 @@ export async function executeCompact(
|
|||||||
path: { sessionID },
|
path: { sessionID },
|
||||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}, 500)
|
}, 500);
|
||||||
return
|
return;
|
||||||
} catch {}
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
await (client as Client).tui
|
await (client as Client).tui
|
||||||
@@ -558,11 +631,11 @@ export async function executeCompact(
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSessionState(autoCompactState, sessionID)
|
clearSessionState(autoCompactState, sessionID);
|
||||||
|
|
||||||
await (client as Client).tui
|
await (client as Client).tui
|
||||||
.showToast({
|
.showToast({
|
||||||
@@ -573,5 +646,8 @@ export async function executeCompact(
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
|
} finally {
|
||||||
|
autoCompactState.compactionInProgress.delete(sessionID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user