-
Notifications
You must be signed in to change notification settings - Fork 0
fix(server): resolve SDK session ID for headless resume #351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
abc55ab
007b4ac
aab4795
e7887b2
d821082
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,7 +28,9 @@ export const clientStore = createMitzoStore({ | |
| }); | ||
|
|
||
| // Sync localStorage model preference into the store so sendMessage() includes it | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 style: The comment |
||
| clientStore.getState().setModel(getPreferredModel()); | ||
| if (typeof window !== 'undefined') { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 missing_tests: The new
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 missing_tests: The |
||
| clientStore.getState().setModel(getPreferredModel()); | ||
| } | ||
|
|
||
| // Wire Capacitor app lifecycle → force WS reconnect on resume, send suspend on background | ||
| registerCapacitorLifecycle( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', () => { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 missing_tests: The 'resume resolves SDK session ID' tests are source-text assertions (string-matching
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 style: The earlier source-reading test block (line 285-288) has a rationale comment explaining why behavioral testing is impractical and source-code assertions are used instead. This new
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 missing_tests: The structural tests verify that resume resolution code exists in the source text, but there is no behavioral test confirming that when
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 missing_tests: There is no structural test asserting that the resolved UUID (not the worktree ID) is logged or traceable on fallback. When |
||
| 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', () => { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 style: The comment '// Must use ternary (BASE_REPO ? ...) not && (which returns '' for empty string)' is helpful rationale, but the assertion |
||
| // 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', () => { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 style: The assertion |
||
| 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; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 bugs: The |
||
| (BASE_REPO ? getSessionSdkId(BASE_REPO, options.resume) : undefined) ?? options.resume; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 unsafe_assumptions: |
||
| } | ||
|
|
||
| try { | ||
| const q = query({ | ||
| prompt: inputQueue as AsyncIterable<SDKUserMessage>, | ||
|
|
@@ -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, { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 missing_tests: No test file exists for session-index.ts at all. The new |
||
| const entries = readIndex(repoPath); | ||
| const entry = entries.find((e) => e.id === wtId); | ||
| return entry?.sdk_session_id; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 style:
import.meta.dirnameis a Node 21+ feature (and supported by Vitest). The server tests already use it, so this is consistent. Just noting it requires Node >= 21 at test-time.