From abc55abce8030d4fcd0c3ba1c222b8ce345d7239 Mon Sep 17 00:00:00 2001 From: dimakis Date: Fri, 22 May 2026 13:30:38 +0100 Subject: [PATCH 1/5] fix(server): resolve SDK session ID for headless resume Headless sessions created via POST /api/sessions fail to resume because the query loop passes the worktree session ID (e.g. "2026-05-22-xxxx") to the SDK's --resume parameter, which expects a UUID. The SDK rejects the worktree ID format. Add getSessionSdkId() to look up the stored SDK UUID from the session index, and use it in chat.ts when constructing the resume parameter. Falls back to the original value if no SDK ID is stored. Co-Authored-By: Claude Opus 4.6 --- server/chat.ts | 4 ++-- server/session-index.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/server/chat.ts b/server/chat.ts index 819914f9..52096c6e 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -63,7 +63,7 @@ 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'; @@ -904,7 +904,7 @@ async function _startChatInner( allowedTools: [...modeAllowed, ...mcpAllowed, ...extraTools], thinking: resolveThinking(options.model), ...(options.model ? { model: parseModelSpec(options.model).model } : {}), - ...(options.resume ? { resume: options.resume } : {}), + ...(options.resume ? { resume: getSessionSdkId(BASE_REPO, options.resume) ?? options.resume } : {}), ...(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..80abdcb0 100644 --- a/server/session-index.ts +++ b/server/session-index.ts @@ -155,3 +155,13 @@ export function updateSessionSdkId(repoPath: string, wtId: string, sdkSessionId: upsertEntry(repoPath, { id: wtId, sdk_session_id: sdkSessionId }); } + +/** + * Get the SDK session ID for a given worktree ID. + * Returns undefined if the entry doesn't exist or sdk_session_id is not set. + */ +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; +} From 007b4aca8d4a22c1598c9d22c754462fa0a3b64c Mon Sep 17 00:00:00 2001 From: dimakis Date: Fri, 22 May 2026 13:38:18 +0100 Subject: [PATCH 2/5] fix(server): address review comments on headless resume - Guard BASE_REPO before calling getSessionSdkId to avoid null arg - Trim verbose JSDoc to single-line comment - Add 3 unit tests for getSessionSdkId covering happy path + edge cases Co-Authored-By: Claude Opus 4.6 --- server/__tests__/session-index.test.ts | 20 ++++++++++++++++++++ server/chat.ts | 13 +++++++++++-- server/session-index.ts | 5 +---- 3 files changed, 32 insertions(+), 6 deletions(-) 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 52096c6e..aa0bd756 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, getSessionSdkId } from './session-index.js'; +import { + registerSession, + updateSessionTitle, + finalizeCloseout, + getSessionSdkId, +} from './session-index.js'; import { createLogger } from './logger.js'; import { withSpan, withSpanAsync } from './tracing.js'; @@ -904,7 +909,11 @@ async function _startChatInner( allowedTools: [...modeAllowed, ...mcpAllowed, ...extraTools], thinking: resolveThinking(options.model), ...(options.model ? { model: parseModelSpec(options.model).model } : {}), - ...(options.resume ? { resume: getSessionSdkId(BASE_REPO, options.resume) ?? options.resume } : {}), + ...(options.resume + ? { + resume: (BASE_REPO && getSessionSdkId(BASE_REPO, options.resume)) ?? options.resume, + } + : {}), ...(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 80abdcb0..d37cf140 100644 --- a/server/session-index.ts +++ b/server/session-index.ts @@ -156,10 +156,7 @@ export function updateSessionSdkId(repoPath: string, wtId: string, sdkSessionId: upsertEntry(repoPath, { id: wtId, sdk_session_id: sdkSessionId }); } -/** - * Get the SDK session ID for a given worktree ID. - * Returns undefined if the entry doesn't exist or sdk_session_id is not set. - */ +/** 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); From aab479540930d0ea33e3c858fab2b597d1a74692 Mon Sep 17 00:00:00 2001 From: dimakis Date: Fri, 22 May 2026 13:57:34 +0100 Subject: [PATCH 3/5] =?UTF-8?q?fix(server):=20address=20second=20review=20?= =?UTF-8?q?=E2=80=94=20guard,=20warn,=20and=20test=20resume=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard client-store model sync with typeof window check for SSR safety - Add log.warn when REPO_PATH is unset during resume (silent degradation) - Add 4 structural tests verifying getSessionSdkId integration in chat.ts Co-Authored-By: Claude Opus 4.6 --- frontend/src/client-store.ts | 4 +++- server/__tests__/chat.test.ts | 29 +++++++++++++++++++++++++++++ server/chat.ts | 11 ++++++++--- 3 files changed, 40 insertions(+), 4 deletions(-) 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..8e28a7f4 100644 --- a/server/__tests__/chat.test.ts +++ b/server/__tests__/chat.test.ts @@ -332,6 +332,35 @@ describe('startChat stores user message for resumed sessions', () => { }); }); +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 before calling getSessionSdkId', () => { + expect(chatSource).toContain('BASE_REPO && getSessionSdkId(BASE_REPO'); + }); + + it('falls back to raw options.resume when lookup returns undefined', () => { + // The ?? operator provides the fallback + expect(chatSource).toMatch(/getSessionSdkId\(.*\)\)\s*\?\?\s*options\.resume/); + }); + + 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/chat.ts b/server/chat.ts index aa0bd756..38f832e4 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -910,9 +910,14 @@ async function _startChatInner( thinking: resolveThinking(options.model), ...(options.model ? { model: parseModelSpec(options.model).model } : {}), ...(options.resume - ? { - resume: (BASE_REPO && getSessionSdkId(BASE_REPO, options.resume)) ?? options.resume, - } + ? (() => { + if (!BASE_REPO) { + log.warn('REPO_PATH unset — resume will use raw worktree ID, SDK may reject it'); + } + return { + resume: (BASE_REPO && getSessionSdkId(BASE_REPO, options.resume)) ?? options.resume, + }; + })() : {}), ...(Object.keys(allMcpServers).length > 0 ? { mcpServers: allMcpServers } : {}), ...(hooks ? { hooks } : {}), From e7887b22d478c7d220347f23d35abd5a90a4ecd2 Mon Sep 17 00:00:00 2001 From: dimakis Date: Fri, 22 May 2026 14:14:13 +0100 Subject: [PATCH 4/5] fix(server): fix empty-string falsy bug in resume resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `&&` with ternary to avoid `'' && x` → `''` which `??` won't catch - Extract resolvedResume as local variable (eliminates IIFE) - Update structural tests to match new pattern Co-Authored-By: Claude Opus 4.6 --- server/__tests__/chat.test.ts | 15 +++++++++++---- server/chat.ts | 21 +++++++++++---------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/server/__tests__/chat.test.ts b/server/__tests__/chat.test.ts index 8e28a7f4..7411cca8 100644 --- a/server/__tests__/chat.test.ts +++ b/server/__tests__/chat.test.ts @@ -345,13 +345,20 @@ describe('resume resolves SDK session ID', () => { expect(chatSource).toContain('getSessionSdkId(BASE_REPO, options.resume)'); }); - it('guards BASE_REPO before calling getSessionSdkId', () => { - expect(chatSource).toContain('BASE_REPO && getSessionSdkId(BASE_REPO'); + 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', () => { - // The ?? operator provides the fallback - expect(chatSource).toMatch(/getSessionSdkId\(.*\)\)\s*\?\?\s*options\.resume/); + 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', () => { diff --git a/server/chat.ts b/server/chat.ts index 38f832e4..e970bbb0 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -891,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, @@ -909,16 +919,7 @@ async function _startChatInner( allowedTools: [...modeAllowed, ...mcpAllowed, ...extraTools], thinking: resolveThinking(options.model), ...(options.model ? { model: parseModelSpec(options.model).model } : {}), - ...(options.resume - ? (() => { - if (!BASE_REPO) { - log.warn('REPO_PATH unset — resume will use raw worktree ID, SDK may reject it'); - } - return { - resume: (BASE_REPO && getSessionSdkId(BASE_REPO, options.resume)) ?? options.resume, - }; - })() - : {}), + ...(resolvedResume ? { resume: resolvedResume } : {}), ...(Object.keys(allMcpServers).length > 0 ? { mcpServers: allMcpServers } : {}), ...(hooks ? { hooks } : {}), canUseTool: buildPermissionHandler(clientId, registry, { From d821082d07947280820142cf1eddb06d049106a8 Mon Sep 17 00:00:00 2001 From: dimakis Date: Fri, 22 May 2026 14:28:18 +0100 Subject: [PATCH 5/5] fix(server): add rationale comments and window guard regression test Add structural rationale comment to resume SDK ID test block. Add client-store window guard test to lock in the typeof check. Co-Authored-By: Claude Opus 4.6 --- frontend/src/__tests__/client-store.test.ts | 19 +++++++++++++++++++ server/__tests__/chat.test.ts | 2 ++ 2 files changed, 21 insertions(+) create mode 100644 frontend/src/__tests__/client-store.test.ts 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/server/__tests__/chat.test.ts b/server/__tests__/chat.test.ts index 7411cca8..b4ee95ec 100644 --- a/server/__tests__/chat.test.ts +++ b/server/__tests__/chat.test.ts @@ -332,6 +332,8 @@ 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;