Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions frontend/src/__tests__/client-store.test.ts
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');
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: import.meta.dirname is 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.

});

it('guards model preference sync with typeof window check', () => {
expect(source).toContain("typeof window !== 'undefined'");
expect(source).toContain('getPreferredModel()');
});
});
4 changes: 3 additions & 1 deletion frontend/src/client-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export const clientStore = createMitzoStore({
});

// Sync localStorage model preference into the store so sendMessage() includes it
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The comment // Sync localStorage model preference into the store so sendMessage() includes it explains the 'why' well, which is good. However, this is a module-level side effect that runs on import. If client-store.ts is ever imported in a test or SSR context, it will silently read from localStorage. Current usage is fine (single-page app entry point), but if this file's import surface grows, consider guarding with a typeof window !== 'undefined' check.

clientStore.getState().setModel(getPreferredModel());
if (typeof window !== 'undefined') {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: The new setModel(getPreferredModel()) bootstrap has no test coverage. A unit test verifying that clientStore.getState().config.modelId matches the localStorage value after module initialization would lock in the behavior. [fixable]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: The typeof window guard is a reasonable defensive fix (likely for Vitest imports in a non-browser environment), but there is no corresponding test verifying that importing client-store.ts in a windowless context no longer throws. If this guard was added to fix a specific failure, a regression test would lock it in. [fixable]

clientStore.getState().setModel(getPreferredModel());
}

// Wire Capacitor app lifecycle → force WS reconnect on resume, send suspend on background
registerCapacitorLifecycle(
Expand Down
38 changes: 38 additions & 0 deletions server/__tests__/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 chat.ts source code). While this pattern is established in the file for other hard-to-mock SDK flows, these tests won't catch the ?? vs || bug on line 918 — they verify substring presence but not runtime semantics. A unit test for getSessionSdkId itself (which exists in session-index.test.ts) is solid, but the integration between the lookup and the fallback is only structurally verified, not behaviorally.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 describe block follows the same pattern but omits the rationale, making it less obvious to future readers why these tests read raw source instead of exercising the function. [fixable]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 options.resume is a worktree ID with a stored sdk_session_id, the correct UUID actually reaches the SDK query() call. The structural tests are a reasonable pragmatic choice given the difficulty of mocking the Agent SDK (and the project already uses this pattern), but a comment acknowledging this gap or a future TODO would help.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 getSessionSdkId returns undefined and the raw worktree ID is used, the only signal is the absence of the UUID — no log line distinguishes 'resolved successfully' from 'fell back to worktree ID'. A debug/info log on successful resolution would aid troubleshooting and could be structurally asserted. [fixable]

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', () => {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 expect(chatSource).toContain('BASE_REPO ? getSessionSdkId(BASE_REPO') is fragile — any whitespace or formatting change (e.g. Prettier wrapping the line) will break this test without any behavioral change. This is an inherent limitation of all the structural tests here, but this particular assertion is especially tight since it spans across a ternary expression.

// 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', () => {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The assertion expect(chatSource).toContain('?? options.resume') is fragile — it matches any occurrence of ?? options.resume anywhere in chat.ts, not specifically in the resume resolution block. If another use of ?? options.resume is added elsewhere, this test would still pass even if the actual resolution fallback were removed. Consider matching a more specific string like getSessionSdkId(BASE_REPO, options.resume) : undefined) ?? options.resume to anchor it to the correct code path. [fixable]

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;

Expand Down
20 changes: 20 additions & 0 deletions server/__tests__/session-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
registerSession,
updateSessionTitle,
updateSessionSdkId,
getSessionSdkId,
finalizeCloseout,
} from '../session-index.js';

Expand Down Expand Up @@ -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();
});
});
});
19 changes: 17 additions & 2 deletions server/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 =
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: The getSessionSdkId call is synchronous I/O (reads YAML from disk via readFileSync inside readIndex). This runs on the main event loop inside an async function that could have used an async read. For a single resume this is unlikely to cause issues, but it's worth noting — if the session index file grows large or disk is slow, this blocks the event loop. Not critical for current usage patterns but worth considering if resume frequency increases. [fixable]

(BASE_REPO ? getSessionSdkId(BASE_REPO, options.resume) : undefined) ?? options.resume;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: getSessionSdkId performs synchronous YAML file I/O (readFileSync inside readIndex) on every resume call. This is fine for the current call volume, but if resume frequency increases, this will block the event loop. The existing updateSessionSdkId/readIndex callers already do synchronous I/O, so this is consistent with the codebase — flagging it as a known trade-off, not a blocker.

}

try {
const q = query({
prompt: inputQueue as AsyncIterable<SDKUserMessage>,
Expand All @@ -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, {
Expand Down
7 changes: 7 additions & 0 deletions server/session-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 getSessionSdkId function (and the existing updateSessionSdkId) lack unit tests. Given the project's TDD requirement, this should have tests covering: (1) returns undefined when entry doesn't exist, (2) returns undefined when sdk_session_id is not set, (3) returns the stored SDK session ID when present. [fixable]

const entries = readIndex(repoPath);
const entry = entries.find((e) => e.id === wtId);
return entry?.sdk_session_id;
}
Loading