Skip to content

Commit c6f2abf

Browse files
committed
feat(session): rebuild session state to support restoring snippet from history.
1 parent f27c2c4 commit c6f2abf

3 files changed

Lines changed: 392 additions & 2 deletions

File tree

src/common/state.ts

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import * as fs from "fs";
12
import * as path from "path";
3+
import { readTextFileWithMetadata } from "./file-utils";
24
import { posixPathToWindowsPath } from "./shell-utils";
35

46
export type FileLineEnding = "LF" | "CRLF";
@@ -25,6 +27,11 @@ export type FileSnippet = {
2527
scopeType: "snippet" | "full";
2628
};
2729

30+
export type SessionStateHistoryMessage = {
31+
role?: unknown;
32+
content?: unknown;
33+
};
34+
2835
const fileStatesBySession = new Map<string, Map<string, FileState>>();
2936
const snippetsBySession = new Map<string, Map<string, FileSnippet>>();
3037
const snippetCountersBySession = new Map<string, number>();
@@ -39,9 +46,24 @@ export function clearSessionState(sessionId: string): void {
3946
fileStatesBySession.delete(sessionId);
4047
snippetsBySession.delete(sessionId);
4148
snippetCountersBySession.delete(sessionId);
49+
fullFileSnippetCountersBySession.delete(sessionId);
4250
fileVersionsBySession.delete(sessionId);
4351
}
4452

53+
export function hasSessionState(sessionId: string): boolean {
54+
if (!sessionId) {
55+
return false;
56+
}
57+
58+
return Boolean(
59+
fileStatesBySession.get(sessionId)?.size ||
60+
snippetsBySession.get(sessionId)?.size ||
61+
snippetCountersBySession.has(sessionId) ||
62+
fullFileSnippetCountersBySession.has(sessionId) ||
63+
fileVersionsBySession.get(sessionId)?.size
64+
);
65+
}
66+
4567
export function normalizeFilePath(filePath: string, platform: NodeJS.Platform = process.platform): string {
4668
const nativePath = normalizeNativeFilePath(filePath, platform);
4769
return platform === "win32" ? path.win32.normalize(nativePath) : path.normalize(nativePath);
@@ -178,6 +200,32 @@ export function createFullFileSnippet(
178200
return createSnippetWithId(sessionId, filePath, startLine, endLine, preview, `full_file_${nextCounter}`, "full");
179201
}
180202

203+
export function restoreSnippet(
204+
sessionId: string,
205+
snippet: {
206+
id: string;
207+
filePath: string;
208+
startLine: number;
209+
endLine: number;
210+
preview?: string;
211+
scopeType?: FileSnippet["scopeType"];
212+
}
213+
): FileSnippet | null {
214+
const restored = createSnippetWithId(
215+
sessionId,
216+
snippet.filePath,
217+
snippet.startLine,
218+
snippet.endLine,
219+
snippet.preview ?? "",
220+
snippet.id,
221+
snippet.scopeType ?? inferSnippetScopeType(snippet.id)
222+
);
223+
if (restored) {
224+
updateSnippetCounters(sessionId, snippet.id);
225+
}
226+
return restored;
227+
}
228+
181229
function createSnippetWithId(
182230
sessionId: string,
183231
filePath: string,
@@ -210,6 +258,27 @@ function createSnippetWithId(
210258
return snippet;
211259
}
212260

261+
function inferSnippetScopeType(id: string): FileSnippet["scopeType"] {
262+
return id.startsWith("full_file_") ? "full" : "snippet";
263+
}
264+
265+
function updateSnippetCounters(sessionId: string, id: string): void {
266+
const fullFileMatch = /^full_file_(\d+)$/.exec(id);
267+
if (fullFileMatch) {
268+
const nextCounter = Number(fullFileMatch[1]) + 1;
269+
const current = fullFileSnippetCountersBySession.get(sessionId) ?? 0;
270+
fullFileSnippetCountersBySession.set(sessionId, Math.max(current, nextCounter));
271+
return;
272+
}
273+
274+
const snippetMatch = /^snippet_(\d+)$/.exec(id);
275+
if (snippetMatch) {
276+
const currentCounter = Number(snippetMatch[1]);
277+
const current = snippetCountersBySession.get(sessionId) ?? 0;
278+
snippetCountersBySession.set(sessionId, Math.max(current, currentCounter));
279+
}
280+
}
281+
213282
export function getSnippet(sessionId: string, snippetId: string): FileSnippet | null {
214283
if (!sessionId || !snippetId) {
215284
return null;
@@ -220,3 +289,247 @@ export function getSnippet(sessionId: string, snippetId: string): FileSnippet |
220289
export function hasSnippetOutdatedFileVersion(sessionId: string, snippet: FileSnippet): boolean {
221290
return getFileVersion(sessionId, snippet.filePath) > snippet.fileVersion;
222291
}
292+
293+
export function rebuildSessionStateFromHistory(
294+
sessionId: string,
295+
messages: Iterable<SessionStateHistoryMessage>
296+
): void {
297+
if (!sessionId || hasSessionState(sessionId)) {
298+
return;
299+
}
300+
301+
for (const message of messages) {
302+
if (message.role !== "tool" || typeof message.content !== "string") {
303+
continue;
304+
}
305+
306+
const result = parsePersistedToolResult(message.content);
307+
if (!result || result.ok !== true) {
308+
continue;
309+
}
310+
311+
const metadata = asRecord(result.metadata);
312+
if (!metadata) {
313+
continue;
314+
}
315+
316+
if (result.name === "read") {
317+
rebuildReadResult(sessionId, result, metadata);
318+
} else if (result.name === "edit") {
319+
rebuildEditResult(sessionId, metadata);
320+
} else if (result.name === "write") {
321+
rebuildWriteResult(sessionId, metadata);
322+
}
323+
}
324+
}
325+
326+
function rebuildReadResult(
327+
sessionId: string,
328+
result: Record<string, unknown>,
329+
metadata: Record<string, unknown>
330+
): void {
331+
const snippet = asRecord(metadata.snippet);
332+
if (!snippet) {
333+
return;
334+
}
335+
336+
const restored = restoreSnippetFromRecord(sessionId, snippet, {
337+
idKey: "id",
338+
filePathKey: "filePath",
339+
startLineKey: "startLine",
340+
endLineKey: "endLine",
341+
preview: typeof result.output === "string" ? result.output : "",
342+
});
343+
if (!restored) {
344+
return;
345+
}
346+
347+
refreshRebuiltFileState(sessionId, restored.filePath, {
348+
scopeType: restored.scopeType,
349+
startLine: restored.startLine,
350+
endLine: restored.endLine,
351+
incrementVersion: false,
352+
});
353+
}
354+
355+
function rebuildEditResult(sessionId: string, metadata: Record<string, unknown>): void {
356+
const scope = asRecord(metadata.scope);
357+
if (scope) {
358+
restoreSnippetFromRecord(sessionId, scope, {
359+
idKey: "snippet_id",
360+
filePathKey: "file_path",
361+
startLineKey: "start_line",
362+
endLineKey: "end_line",
363+
scopeType: metadata.read_scope_type === "full" ? "full" : undefined,
364+
});
365+
}
366+
367+
const scopeFilePath = typeof scope?.file_path === "string" ? scope.file_path : undefined;
368+
rebuildCandidateSnippets(sessionId, metadata, scopeFilePath);
369+
370+
const filePath = typeof metadata.file_path === "string" ? metadata.file_path : scopeFilePath;
371+
if (filePath && metadata.cache_refreshed === true) {
372+
refreshRebuiltFileState(sessionId, filePath, { incrementVersion: true });
373+
}
374+
}
375+
376+
function rebuildWriteResult(sessionId: string, metadata: Record<string, unknown>): void {
377+
if (metadata.cache_refreshed !== true || typeof metadata.file_path !== "string") {
378+
return;
379+
}
380+
381+
refreshRebuiltFileState(sessionId, metadata.file_path, { incrementVersion: true });
382+
}
383+
384+
function rebuildCandidateSnippets(
385+
sessionId: string,
386+
metadata: Record<string, unknown>,
387+
filePath: string | undefined
388+
): void {
389+
if (!filePath) {
390+
return;
391+
}
392+
393+
const candidates = Array.isArray(metadata.candidates) ? metadata.candidates : [];
394+
for (const candidate of candidates) {
395+
const record = asRecord(candidate);
396+
if (!record) {
397+
continue;
398+
}
399+
restoreSnippetFromRecord(
400+
sessionId,
401+
{ ...record, file_path: filePath },
402+
{
403+
idKey: "snippet_id",
404+
filePathKey: "file_path",
405+
startLineKey: "start_line",
406+
endLineKey: "end_line",
407+
scopeType: "snippet",
408+
preview: typeof record.preview === "string" ? record.preview : "",
409+
}
410+
);
411+
}
412+
413+
const closestMatch = asRecord(metadata.closest_match);
414+
if (closestMatch) {
415+
restoreSnippetFromRecord(
416+
sessionId,
417+
{ ...closestMatch, file_path: filePath },
418+
{
419+
idKey: "snippet_id",
420+
filePathKey: "file_path",
421+
startLineKey: "start_line",
422+
endLineKey: "end_line",
423+
scopeType: "snippet",
424+
preview: typeof closestMatch.preview === "string" ? closestMatch.preview : "",
425+
}
426+
);
427+
}
428+
}
429+
430+
function restoreSnippetFromRecord(
431+
sessionId: string,
432+
record: Record<string, unknown>,
433+
options: {
434+
idKey: string;
435+
filePathKey: string;
436+
startLineKey: string;
437+
endLineKey: string;
438+
preview?: string;
439+
scopeType?: FileSnippet["scopeType"];
440+
}
441+
): FileSnippet | null {
442+
const rawId = record[options.idKey];
443+
const rawFilePath = record[options.filePathKey];
444+
const id = typeof rawId === "string" ? rawId.trim() : "";
445+
const filePath = typeof rawFilePath === "string" ? normalizeFilePath(rawFilePath) : "";
446+
const startLine = toPositiveInteger(record[options.startLineKey]);
447+
const endLine = toPositiveInteger(record[options.endLineKey]);
448+
if (!id || !filePath || startLine === null || endLine === null) {
449+
return null;
450+
}
451+
452+
return restoreSnippet(sessionId, {
453+
id,
454+
filePath,
455+
startLine,
456+
endLine,
457+
preview: options.preview,
458+
scopeType: options.scopeType,
459+
});
460+
}
461+
462+
function refreshRebuiltFileState(
463+
sessionId: string,
464+
rawFilePath: string,
465+
options: {
466+
scopeType?: FileSnippet["scopeType"];
467+
startLine?: number;
468+
endLine?: number;
469+
incrementVersion?: boolean;
470+
} = {}
471+
): void {
472+
const filePath = normalizeFilePath(rawFilePath);
473+
if (!filePath || !fs.existsSync(filePath)) {
474+
return;
475+
}
476+
477+
try {
478+
const stat = fs.statSync(filePath);
479+
if (stat.isDirectory()) {
480+
return;
481+
}
482+
483+
const metadata = readTextFileWithMetadata(filePath);
484+
const isPartialView = options.scopeType === "snippet";
485+
const content = isPartialView
486+
? metadata.content
487+
.split("\n")
488+
.slice((options.startLine ?? 1) - 1, options.endLine)
489+
.join("\n")
490+
: metadata.content;
491+
492+
recordFileState(
493+
sessionId,
494+
{
495+
filePath,
496+
content,
497+
timestamp: metadata.timestamp,
498+
offset: isPartialView ? options.startLine : undefined,
499+
limit:
500+
isPartialView && options.startLine !== undefined && options.endLine !== undefined
501+
? Math.max(1, options.endLine - options.startLine + 1)
502+
: undefined,
503+
isPartialView,
504+
encoding: metadata.encoding,
505+
lineEndings: metadata.lineEndings,
506+
},
507+
{ incrementVersion: options.incrementVersion }
508+
);
509+
} catch {
510+
// Best-effort restore: later tool execution will return the precise filesystem error.
511+
}
512+
}
513+
514+
function parsePersistedToolResult(content: string): Record<string, unknown> | null {
515+
try {
516+
return asRecord(JSON.parse(content));
517+
} catch {
518+
return null;
519+
}
520+
}
521+
522+
function asRecord(value: unknown): Record<string, unknown> | null {
523+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
524+
return null;
525+
}
526+
return value as Record<string, unknown>;
527+
}
528+
529+
function toPositiveInteger(value: unknown): number | null {
530+
const numberValue = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
531+
if (!Number.isInteger(numberValue) || numberValue < 1) {
532+
return null;
533+
}
534+
return numberValue;
535+
}

src/session.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { logApiError } from "./common/error-logger";
3131
import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger";
3232
import { killProcessTree } from "./common/process-tree";
3333
import { GitFileHistory } from "./common/file-history";
34-
import { getSnippet } from "./common/state";
34+
import { clearSessionState, getSnippet, rebuildSessionStateFromHistory } from "./common/state";
3535
import {
3636
appendProjectPermissionAllows,
3737
buildPermissionToolExecution,
@@ -45,7 +45,6 @@ import {
4545
type UserToolPermission,
4646
} from "./common/permissions";
4747
import { clearSessionWorkingDir } from "./tools/bash-handler";
48-
import { clearSessionState } from "./common/state";
4948

5049
export type { PermissionScope } from "./settings";
5150
export type {
@@ -1143,6 +1142,7 @@ ${skillMd}
11431142
const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } =
11441143
this.createOpenAIClient();
11451144
const now = new Date().toISOString();
1145+
rebuildSessionStateFromHistory(sessionId, this.listSessionMessages(sessionId));
11461146

11471147
if (!client) {
11481148
this.updateSessionEntry(sessionId, (entry) => ({

0 commit comments

Comments
 (0)