Skip to content

Commit 11184d3

Browse files
Merge pull request #106 from ai-action/feat/sessions
2 parents 218a429 + 5a0d2ac commit 11184d3

5 files changed

Lines changed: 84 additions & 7 deletions

File tree

src/cli.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ type RunAction = (model: string, prompt: string) => Promise<void>;
22
type ResumeAction = (sessionId: string) => Promise<void>;
33

44
const {
5+
color,
56
createSystemMessage,
67
executeTool,
78
loadSession,
@@ -12,6 +13,7 @@ const {
1213
write,
1314
writeError,
1415
} = vi.hoisted(() => ({
16+
color: vi.fn((text: string) => text),
1517
createSystemMessage: vi.fn(() => ({
1618
role: 'system',
1719
content: 'system prompt',
@@ -38,7 +40,7 @@ vi.mock('./utils', () => ({
3840
ollama: { streamChat },
3941
screen: { reset: mockReset },
4042
session: { loadSession },
41-
terminal: { write, writeError },
43+
terminal: { color, write, writeError },
4244
tools: { TOOLS: ['mock-tool'], executeTool },
4345
}));
4446
vi.mock('./tui', () => ({ renderApp }));
@@ -249,7 +251,7 @@ describe('cli', () => {
249251

250252
it('loads the requested session and renders the TUI for resume', async () => {
251253
loadSession.mockReturnValueOnce({
252-
metadata: { id: 'session-1' },
254+
metadata: { id: 'session-1', directory: process.cwd() },
253255
messages: [],
254256
});
255257

@@ -260,6 +262,37 @@ describe('cli', () => {
260262
expect(renderApp).toHaveBeenCalledWith('session-1');
261263
});
262264

265+
it('allows resume when session has no directory field (legacy session)', async () => {
266+
loadSession.mockReturnValueOnce({
267+
metadata: { id: 'session-1', directory: undefined },
268+
messages: [],
269+
});
270+
271+
await commandState.resumeAction?.('session-1');
272+
273+
expect(renderApp).toHaveBeenCalledWith('session-1');
274+
expect(writeError).not.toHaveBeenCalled();
275+
});
276+
277+
it('blocks TUI and errors when resuming a session from a different directory', async () => {
278+
loadSession.mockReturnValueOnce({
279+
metadata: { id: 'session-1', directory: '/other/project' },
280+
messages: [],
281+
});
282+
283+
await commandState.resumeAction?.('session-1');
284+
285+
expect(color).toHaveBeenCalledWith(
286+
expect.stringContaining(
287+
'Cannot resume: session belongs to /other/project',
288+
),
289+
'yellow',
290+
);
291+
expect(writeError).toHaveBeenCalledOnce();
292+
expect(process.exitCode).toBe(1);
293+
expect(renderApp).not.toHaveBeenCalled();
294+
});
295+
263296
it('reports resume errors and sets exit code', async () => {
264297
loadSession.mockImplementationOnce(() => {
265298
throw new Error('Session not found: missing');

src/cli.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { realpathSync } from 'node:fs';
44

55
import cac from 'cac';
66

7-
import { PACKAGE, ROLE } from './constants';
7+
import { PACKAGE, ROLE, UI } from './constants';
88
import type { ToolName } from './types';
99
import { agents, ollama, screen, session, terminal, tools } from './utils';
1010

@@ -30,7 +30,20 @@ cli
3030
.command('resume <sessionId>', 'Resume a saved session')
3131
.action(async (sessionId: string) => {
3232
try {
33-
session.loadSession(sessionId);
33+
const loaded = session.loadSession(sessionId);
34+
if (
35+
loaded.metadata.directory &&
36+
loaded.metadata.directory !== process.cwd()
37+
) {
38+
terminal.writeError(
39+
terminal.color(
40+
`${UI.WARNING} Cannot resume: session belongs to ${loaded.metadata.directory}\n`,
41+
'yellow',
42+
),
43+
);
44+
process.exitCode = 1;
45+
return;
46+
}
3447
await launchTui(sessionId);
3548
} catch (error) {
3649
const message = error instanceof Error ? error.message : 'Unknown error';

src/components/App/App.test.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,19 +271,23 @@ describe('App', () => {
271271
updatedAt: '2026-05-11T00:00:00.000Z',
272272
title: 'New session',
273273
model,
274+
directory: process.cwd(),
274275
},
275276
messages: [],
276277
}));
278+
277279
loadSession.mockImplementation((sessionId: string) => ({
278280
metadata: {
279281
id: sessionId,
280282
createdAt: '2026-05-11T00:00:00.000Z',
281283
updatedAt: '2026-05-11T00:00:00.000Z',
282284
title: sessionId,
283285
model: 'gemma4',
286+
directory: process.cwd(),
284287
},
285288
messages: [],
286289
}));
290+
287291
listSessions.mockReturnValue([
288292
{
289293
id: 'session-0',
@@ -293,20 +297,24 @@ describe('App', () => {
293297
model: 'gemma4',
294298
},
295299
]);
300+
296301
appendMessage.mockImplementation((_sessionId, _message, model: string) => ({
297302
id: 'session-0',
298303
createdAt: '2026-05-11T00:00:00.000Z',
299304
updatedAt: '2026-05-11T00:00:01.000Z',
300305
title: 'Session 0',
301306
model,
307+
directory: process.cwd(),
302308
}));
309+
303310
updateSessionModel.mockImplementation(
304311
(sessionId: string, model: string) => ({
305312
id: sessionId,
306313
createdAt: '2026-05-11T00:00:00.000Z',
307314
updatedAt: '2026-05-11T00:00:00.000Z',
308315
title: sessionId,
309316
model,
317+
directory: process.cwd(),
310318
}),
311319
);
312320
});
@@ -448,14 +456,14 @@ describe('App', () => {
448456

449457
it('prints a resume command when the app exits with session messages', async () => {
450458
deleteSessionIfEmpty.mockReturnValue(false);
451-
const { unmount } = render(<App />);
459+
const { unmount, rerender } = render(<App />);
452460
await time.tick();
453461

454462
capturedCallbacks.onMessagesChange?.([
455463
{ role: 'user', content: 'saved message' },
456464
{ role: 'assistant', content: 'saved reply' },
457465
]);
458-
466+
rerender(<App />);
459467
await time.tick();
460468
unmount();
461469

src/utils/session.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('session', () => {
5252
expect(existsSync(getMetadataPath(session.metadata.id))).toBe(true);
5353
expect(existsSync(getMessagesPath(session.metadata.id))).toBe(true);
5454
expect(session.metadata.title).toBe('New session');
55+
expect(session.metadata.directory).toBe(process.cwd());
5556
expect(session.messages).toEqual([]);
5657
});
5758

@@ -137,6 +138,25 @@ describe('session', () => {
137138
]);
138139
});
139140

141+
it('excludes sessions from other directories when listing', async () => {
142+
const { createSession, listSessions } = await import('./session');
143+
const current = createSession('gemma4');
144+
const other = createSession('llama3');
145+
146+
writeFileSync(
147+
getMetadataPath(other.metadata.id),
148+
JSON.stringify(
149+
{ ...other.metadata, directory: '/other/project' },
150+
null,
151+
2,
152+
) + '\n',
153+
'utf8',
154+
);
155+
156+
const sessions = listSessions();
157+
expect(sessions.map(({ id }) => id)).toEqual([current.metadata.id]);
158+
});
159+
140160
it('updates the stored model without changing updatedAt', async () => {
141161
const { createSession, loadSession, updateSessionModel } =
142162
await import('./session');

src/utils/session.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface SessionMetadata {
2727
updatedAt: string;
2828
title: string;
2929
model: string;
30+
directory: string;
3031
}
3132

3233
export interface SessionRecord {
@@ -140,6 +141,7 @@ export function createSession(model: string): SessionRecord {
140141
updatedAt: now,
141142
title: DEFAULT_TITLE,
142143
model,
144+
directory: process.cwd(),
143145
};
144146

145147
ensureSessionDirectory(id);
@@ -149,7 +151,7 @@ export function createSession(model: string): SessionRecord {
149151
return { metadata, messages: [] };
150152
}
151153

152-
export function listSessions(): SessionMetadata[] {
154+
export function listSessions(directory = process.cwd()): SessionMetadata[] {
153155
if (!existsSync(SESSIONS_DIRECTORY)) {
154156
return [];
155157
}
@@ -163,6 +165,7 @@ export function listSessions(): SessionMetadata[] {
163165
return [];
164166
}
165167
})
168+
.filter((metadata) => metadata.directory === directory)
166169
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
167170
}
168171

0 commit comments

Comments
 (0)