mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
* Preload compact history on first turn after app restart (#753 hedge) Symptom (confirmed on Copilot CLI, originally reported on Codex in #753): after closing and reopening Netcatty, the AI chat UI still shows the prior conversation but the agent responds "this is the beginning of our conversation, no previous records". Earlier context is lost entirely. Root cause: the bridge relied on session/load throwing "not found" to trigger the catch-block fallback that replays compact history. Some ACP agents (Copilot CLI, some Codex builds) silently spawn a new session when handed a stale id instead of erroring. The catch-block never fires → historyReplayFallback stays false → the first turn sends only the latest prompt → agent sees zero context. Fix: when we're creating a new provider process AND telling it to resume an existing session id AND the renderer gave us compact history, preload historyReplayFallback=true as a hedge. If the agent really did reload the session, the replay is ~3KB of redundant context (small waste). If the agent silently started fresh, the replay restores durable constraints + last few raw turns so the first response is coherent. After the first successful streamed turn clears the flag (the round-2 post-stream hook), steady state is back to sending only the latest prompt. Cost is bounded to one replay per app-restart-and-prompt. Test: "replays compact history on the first turn after app restart even when session/load 'succeeds'" — mocks createACPProvider to behave like Copilot CLI (no error thrown, no real resume), asserts the first streamText call carries history+latest (length 2) and the second only latest (length 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix AI session resume and agent switching * Preserve hidden draft when switching agents --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
146 lines
4.2 KiB
TypeScript
146 lines
4.2 KiB
TypeScript
import type {
|
|
AIDraft,
|
|
AIPanelView,
|
|
AISession,
|
|
} from "../../infrastructure/ai/types";
|
|
|
|
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
|
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
|
type ActiveSessionIdMap = Record<string, string | null>;
|
|
|
|
function isInactiveScopedTarget(
|
|
scopeKey: string,
|
|
activeTargetIds: Set<string>,
|
|
): boolean {
|
|
const separatorIndex = scopeKey.indexOf(":");
|
|
if (separatorIndex === -1) return false;
|
|
|
|
const scopeType = scopeKey.slice(0, separatorIndex);
|
|
if (scopeType !== "terminal" && scopeType !== "workspace") return false;
|
|
|
|
const targetId = scopeKey.slice(separatorIndex + 1);
|
|
if (!targetId) return false;
|
|
|
|
return !activeTargetIds.has(targetId);
|
|
}
|
|
|
|
export function pruneInactiveScopedState(
|
|
draftsByScope: DraftsByScope,
|
|
panelViewByScope: PanelViewByScope,
|
|
activeTargetIds: Set<string>,
|
|
): {
|
|
draftsByScope: DraftsByScope;
|
|
panelViewByScope: PanelViewByScope;
|
|
} {
|
|
const nextDraftsByScope = { ...draftsByScope };
|
|
const nextPanelViewByScope = { ...panelViewByScope };
|
|
let draftsChanged = false;
|
|
let panelViewsChanged = false;
|
|
|
|
for (const scopeKey of Object.keys(nextDraftsByScope)) {
|
|
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
|
|
delete nextDraftsByScope[scopeKey];
|
|
draftsChanged = true;
|
|
}
|
|
|
|
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
|
|
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
|
|
delete nextPanelViewByScope[scopeKey];
|
|
panelViewsChanged = true;
|
|
}
|
|
|
|
return {
|
|
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
|
|
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
|
|
};
|
|
}
|
|
|
|
export function pruneInactiveScopedTransientState(
|
|
activeSessionIdMap: ActiveSessionIdMap,
|
|
draftsByScope: DraftsByScope,
|
|
panelViewByScope: PanelViewByScope,
|
|
activeTargetIds: Set<string>,
|
|
): {
|
|
activeSessionIdMap: ActiveSessionIdMap;
|
|
draftsByScope: DraftsByScope;
|
|
panelViewByScope: PanelViewByScope;
|
|
} {
|
|
let activeSessionMapChanged = false;
|
|
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
|
|
|
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
|
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
|
|
activeSessionMapChanged = true;
|
|
continue;
|
|
}
|
|
|
|
nextActiveSessionIdMap[scopeKey] = sessionId;
|
|
}
|
|
|
|
const nextScopedState = pruneInactiveScopedState(
|
|
draftsByScope,
|
|
panelViewByScope,
|
|
activeTargetIds,
|
|
);
|
|
|
|
return {
|
|
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
|
draftsByScope: nextScopedState.draftsByScope,
|
|
panelViewByScope: nextScopedState.panelViewByScope,
|
|
};
|
|
}
|
|
|
|
function isRestorableTerminalSession(session: AISession): boolean {
|
|
return session.scope.type === "terminal"
|
|
&& !!session.scope.hostIds?.length
|
|
&& session.scope.hostIds.some((id) => !id.startsWith("local-") && !id.startsWith("serial-"));
|
|
}
|
|
|
|
export function pruneInactiveScopedSessions(
|
|
sessions: AISession[],
|
|
activeTargetIds: Set<string>,
|
|
/**
|
|
* Session ids currently displayed by any live scope. A session whose
|
|
* `scope.targetId` is inactive but whose id is still in use somewhere
|
|
* (e.g. resumed from history into a different terminal) must not be
|
|
* treated as orphaned — deleting it outright would break the chat the
|
|
* user is actively continuing.
|
|
*/
|
|
activeSessionIds: Set<string> = new Set(),
|
|
): {
|
|
sessions: AISession[];
|
|
orphanedSessionIds: string[];
|
|
} {
|
|
const orphanedSessionIds = sessions
|
|
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
|
.filter((session) => !activeSessionIds.has(session.id))
|
|
.map((session) => session.id);
|
|
|
|
if (orphanedSessionIds.length === 0) {
|
|
return {
|
|
sessions,
|
|
orphanedSessionIds,
|
|
};
|
|
}
|
|
|
|
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
|
let sessionsChanged = false;
|
|
|
|
const nextSessions = sessions.flatMap((session) => {
|
|
if (!orphanedSessionIdSet.has(session.id)) {
|
|
return [session];
|
|
}
|
|
|
|
if (!isRestorableTerminalSession(session)) {
|
|
sessionsChanged = true;
|
|
return [];
|
|
}
|
|
return [session];
|
|
});
|
|
|
|
return {
|
|
sessions: sessionsChanged ? nextSessions : sessions,
|
|
orphanedSessionIds,
|
|
};
|
|
}
|