fix(sessions): fall back when lastDirectory is a deleted worktree

New chats inherited a persisted lastDirectory even after that worktree
was removed, so the first message saved but the prompt never started.
Validate the implicit draft directory, fall back to the active project
only when OpenCode confirms the path is missing, and leave explicit
worktree targets and unknown probes unchanged.

Co-authored-by: serkraser <serkraser@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-15 04:26:39 +00:00
co-authored by serkraser
parent 6b1e677aaf
commit 90e79b04a4
7 changed files with 246 additions and 10 deletions
@@ -9,6 +9,7 @@ let configCalls = 0;
let runtimeKey = 'test-runtime';
const promptAsyncCalls: unknown[][] = [];
const promptAsyncResults: Array<unknown> = [];
const pathGetResults: Array<unknown> = [];
const promptAsyncMock = mock(async (...args: unknown[]) => {
promptAsyncCalls.push(args);
@@ -17,6 +18,12 @@ const promptAsyncMock = mock(async (...args: unknown[]) => {
return next ?? { response: new Response(null, { status: 200 }) };
});
const pathGetMock = mock(async () => {
const next = pathGetResults.shift();
if (next instanceof Error) throw next;
return next ?? { data: { directory: '/workspace/project' } };
});
mock.module('@opencode-ai/sdk/v2', () => ({
createOpencodeClient: mock(() => ({
config: {
@@ -30,6 +37,9 @@ mock.module('@opencode-ai/sdk/v2', () => ({
session: {
promptAsync: promptAsyncMock,
},
path: {
get: pathGetMock,
},
})),
}));
@@ -64,6 +74,17 @@ beforeEach(() => {
runtimeKey = 'test-runtime';
promptAsyncCalls.length = 0;
promptAsyncResults.length = 0;
pathGetResults.length = 0;
});
describe('opencodeClient directory availability', () => {
test('distinguishes a missing directory from an unavailable path probe', async () => {
pathGetResults.push({ error: { code: 'ENOENT', message: 'no such file or directory' } });
expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('missing');
pathGetResults.push(new Error('offline'));
expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown');
});
});
describe('opencodeClient getConfig cache', () => {
+33 -7
View File
@@ -68,6 +68,21 @@ type SdkResult<T> = {
response?: { status?: number };
};
export type DirectoryAvailability = "available" | "missing" | "unknown";
const isMissingDirectoryError = (error: unknown): boolean => {
if (error instanceof FilesystemError) {
return error.reason === "not-found" || error.reason === "not-directory";
}
if (error && typeof error === "object") {
const code = (error as { code?: unknown }).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return true;
}
}
return /\bENOENT\b|\bENOTDIR\b|no such file or directory/i.test(formatSdkError(error));
};
function unwrapSdkData<T>(result: SdkResult<T>, operation: string): T {
if (result.error) {
const status = result.response?.status;
@@ -506,17 +521,28 @@ class OpencodeService {
* This is intentionally NOT the same as local filesystem access in the UI runtime.
*/
async probeDirectory(directory: string): Promise<boolean> {
return (await this.getDirectoryAvailability(directory)) === "available";
}
/**
* Distinguishes a confirmed-missing directory from an unavailable probe.
* Offline, permission, and other transport failures stay `unknown` so callers
* do not treat a temporary outage as proof the path was deleted.
*/
async getDirectoryAvailability(directory: string): Promise<DirectoryAvailability> {
const normalized = this.normalizeCandidatePath(directory);
if (!normalized) {
return false;
return "unknown";
}
try {
const response = await this.client.path.get({ directory: normalized });
const info = response.data as { directory?: unknown } | undefined;
const returned = typeof info?.directory === 'string' ? info.directory : null;
return Boolean(returned && returned.trim().length > 0);
} catch {
return false;
const response = await this.client.path.get({ directory: normalized }) as SdkResult<{ directory?: unknown }>;
if (response.error) {
return isMissingDirectoryError(response.error) ? "missing" : "unknown";
}
const returned = typeof response.data?.directory === "string" ? response.data.directory.trim() : "";
return returned ? "available" : "unknown";
} catch (error) {
return isMissingDirectoryError(error) ? "missing" : "unknown";
}
}