diff --git a/frontend/src/__tests__/client-store.test.ts b/frontend/src/__tests__/client-store.test.ts new file mode 100644 index 00000000..138c1fc7 --- /dev/null +++ b/frontend/src/__tests__/client-store.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +// Structural test: client-store uses model-preference sync at module scope, +// which accesses localStorage. The `typeof window` guard prevents crashes +// when the module is imported in a non-browser context (e.g. Vitest server tests). +describe('client-store window guard', () => { + let source: string; + + beforeAll(() => { + source = readFileSync(join(import.meta.dirname, '..', 'client-store.ts'), 'utf-8'); + }); + + it('guards model preference sync with typeof window check', () => { + expect(source).toContain("typeof window !== 'undefined'"); + expect(source).toContain('getPreferredModel()'); + }); +}); diff --git a/frontend/src/client-store.ts b/frontend/src/client-store.ts index 4dcfaffd..7b22781d 100644 --- a/frontend/src/client-store.ts +++ b/frontend/src/client-store.ts @@ -28,7 +28,9 @@ export const clientStore = createMitzoStore({ }); // Sync localStorage model preference into the store so sendMessage() includes it -clientStore.getState().setModel(getPreferredModel()); +if (typeof window !== 'undefined') { + clientStore.getState().setModel(getPreferredModel()); +} // Wire Capacitor app lifecycle → force WS reconnect on resume, send suspend on background registerCapacitorLifecycle( diff --git a/server/__tests__/chat.test.ts b/server/__tests__/chat.test.ts index 1d3401e1..b4ee95ec 100644 --- a/server/__tests__/chat.test.ts +++ b/server/__tests__/chat.test.ts @@ -332,6 +332,44 @@ describe('startChat stores user message for resumed sessions', () => { }); }); +// Structural tests: resume resolution requires the Agent SDK query() pipeline, +// so we assert against the source rather than invoking startChat() directly. +describe('resume resolves SDK session ID', () => { + let chatSource: string; + + beforeAll(async () => { + const { readFileSync } = await import('fs'); + const { join } = await import('path'); + chatSource = readFileSync(join(import.meta.dirname, '..', 'chat.ts'), 'utf-8'); + }); + + it('calls getSessionSdkId before passing resume to query()', () => { + expect(chatSource).toContain('getSessionSdkId(BASE_REPO, options.resume)'); + }); + + it('guards BASE_REPO with ternary to avoid empty-string falsy bug', () => { + // Must use ternary (BASE_REPO ? ...) not && (which returns '' for empty string) + expect(chatSource).toContain('BASE_REPO ? getSessionSdkId(BASE_REPO'); + }); + + it('falls back to raw options.resume when lookup returns undefined', () => { + expect(chatSource).toContain('?? options.resume'); + }); + + it('resolvedResume is computed before the query() call', () => { + const resolveIdx = chatSource.indexOf('let resolvedResume'); + const queryIdx = chatSource.indexOf('const q = query('); + expect(resolveIdx).toBeGreaterThan(-1); + expect(queryIdx).toBeGreaterThan(resolveIdx); + }); + + it('warns when REPO_PATH is unset during resume', () => { + expect(chatSource).toContain( + 'REPO_PATH unset — resume will use raw worktree ID, SDK may reject it', + ); + }); +}); + describe('isIsolationEnabled', () => { let originalEnv: string | undefined; diff --git a/server/__tests__/session-index.test.ts b/server/__tests__/session-index.test.ts index 4420532c..a278a291 100644 --- a/server/__tests__/session-index.test.ts +++ b/server/__tests__/session-index.test.ts @@ -8,6 +8,7 @@ import { registerSession, updateSessionTitle, updateSessionSdkId, + getSessionSdkId, finalizeCloseout, } from '../session-index.js'; @@ -305,4 +306,23 @@ describe('session-index', () => { expect(readIndex(repoPath)).toEqual([]); }); }); + + describe('getSessionSdkId', () => { + it('returns the stored SDK session ID', () => { + upsertEntry(repoPath, { id: 'sess-1', status: 'active' }); + updateSessionSdkId(repoPath, 'sess-1', 'f2fd42cf-5c9f-4524-be08-9b019c1cc3a2'); + + expect(getSessionSdkId(repoPath, 'sess-1')).toBe('f2fd42cf-5c9f-4524-be08-9b019c1cc3a2'); + }); + + it('returns undefined when entry does not exist', () => { + expect(getSessionSdkId(repoPath, 'nonexistent')).toBeUndefined(); + }); + + it('returns undefined when sdk_session_id is not set', () => { + upsertEntry(repoPath, { id: 'sess-1', status: 'active' }); + + expect(getSessionSdkId(repoPath, 'sess-1')).toBeUndefined(); + }); + }); }); diff --git a/server/chat.ts b/server/chat.ts index 819914f9..e970bbb0 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -63,7 +63,12 @@ export function setSessionChangeCallback(cb: SessionChangeCallback): void { import { EventStore } from './event-store.js'; import { capturePromptComparison } from './prompt-compare.js'; import { shouldAutoRename, extractRecentPrompts, generateSessionName } from './auto-rename.js'; -import { registerSession, updateSessionTitle, finalizeCloseout } from './session-index.js'; +import { + registerSession, + updateSessionTitle, + finalizeCloseout, + getSessionSdkId, +} from './session-index.js'; import { createLogger } from './logger.js'; import { withSpan, withSpanAsync } from './tracing.js'; @@ -886,6 +891,16 @@ async function _startChatInner( })(); capturePromptComparison(wtId, cwd, systemPromptAppend, repoWorktrees).catch(() => {}); + // Resolve SDK session UUID for resume — worktree IDs are not valid SDK session IDs + let resolvedResume: string | undefined; + if (options.resume) { + if (!BASE_REPO) { + log.warn('REPO_PATH unset — resume will use raw worktree ID, SDK may reject it'); + } + resolvedResume = + (BASE_REPO ? getSessionSdkId(BASE_REPO, options.resume) : undefined) ?? options.resume; + } + try { const q = query({ prompt: inputQueue as AsyncIterable, @@ -904,7 +919,7 @@ async function _startChatInner( allowedTools: [...modeAllowed, ...mcpAllowed, ...extraTools], thinking: resolveThinking(options.model), ...(options.model ? { model: parseModelSpec(options.model).model } : {}), - ...(options.resume ? { resume: options.resume } : {}), + ...(resolvedResume ? { resume: resolvedResume } : {}), ...(Object.keys(allMcpServers).length > 0 ? { mcpServers: allMcpServers } : {}), ...(hooks ? { hooks } : {}), canUseTool: buildPermissionHandler(clientId, registry, { diff --git a/server/session-index.ts b/server/session-index.ts index 61083e09..d37cf140 100644 --- a/server/session-index.ts +++ b/server/session-index.ts @@ -155,3 +155,10 @@ export function updateSessionSdkId(repoPath: string, wtId: string, sdkSessionId: upsertEntry(repoPath, { id: wtId, sdk_session_id: sdkSessionId }); } + +/** Look up the SDK session UUID stored for a worktree ID. */ +export function getSessionSdkId(repoPath: string, wtId: string): string | undefined { + const entries = readIndex(repoPath); + const entry = entries.find((e) => e.id === wtId); + return entry?.sdk_session_id; +}