Skip to content

Commit 6add97d

Browse files
committed
feat(mcp): add resume_session MCP tool (#1124)
Add resume_session tool that loads a briefing document and restores session context for cross-session recovery. Includes BriefingLoaderService for parsing briefing markdown and ResumeHandler for MCP tool registration.
1 parent 4f79954 commit 6add97d

7 files changed

Lines changed: 664 additions & 2 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2+
import { BriefingLoaderService } from './briefing-loader.service';
3+
import type { ConfigService } from '../config/config.service';
4+
import type { ContextDocumentService } from './context-document.service';
5+
import * as fs from 'fs/promises';
6+
import { existsSync, readdirSync } from 'fs';
7+
8+
vi.mock('fs/promises');
9+
vi.mock('fs', async importOriginal => {
10+
const actual = await importOriginal<typeof import('fs')>();
11+
return {
12+
...actual,
13+
existsSync: vi.fn(),
14+
readdirSync: vi.fn(),
15+
};
16+
});
17+
18+
function createMockConfigService(projectRoot = '/tmp/test-project'): ConfigService {
19+
return {
20+
getProjectRoot: vi.fn().mockReturnValue(projectRoot),
21+
} as unknown as ConfigService;
22+
}
23+
24+
function createMockContextDocService(): ContextDocumentService {
25+
return {
26+
resetContext: vi.fn().mockResolvedValue({ success: true }),
27+
} as unknown as ContextDocumentService;
28+
}
29+
30+
const SAMPLE_BRIEFING = `# Briefing: Auth Feature
31+
32+
**Mode**: ACT
33+
**Created**: 2026-04-01T10:00:00.000Z
34+
35+
## Decisions
36+
- Use JWT for authentication
37+
- Store tokens in httpOnly cookies
38+
39+
## Changed Files
40+
- src/auth/auth.service.ts
41+
- src/auth/auth.controller.ts
42+
43+
## Pending Tasks
44+
- Add refresh token logic
45+
- Write integration tests
46+
47+
## Resume Command
48+
\`\`\`
49+
ACT continue implementation of "Auth Feature"
50+
\`\`\`
51+
`;
52+
53+
describe('BriefingLoaderService', () => {
54+
let service: BriefingLoaderService;
55+
let mockConfigService: ConfigService;
56+
let mockContextDocService: ContextDocumentService;
57+
58+
beforeEach(() => {
59+
vi.clearAllMocks();
60+
mockConfigService = createMockConfigService();
61+
mockContextDocService = createMockContextDocService();
62+
service = new BriefingLoaderService(mockConfigService, mockContextDocService);
63+
});
64+
65+
afterEach(() => {
66+
vi.restoreAllMocks();
67+
});
68+
69+
describe('findMostRecentBriefing', () => {
70+
it('should return the most recent briefing file', () => {
71+
vi.mocked(existsSync).mockReturnValue(true);
72+
vi.mocked(readdirSync).mockReturnValue([
73+
'2026-03-31T09-00-00-000Z.md',
74+
'2026-04-01T10-00-00-000Z.md',
75+
'2026-03-30T08-00-00-000Z.md',
76+
] as unknown as ReturnType<typeof readdirSync>);
77+
78+
const result = service.findMostRecentBriefing();
79+
expect(result).toContain('2026-04-01T10-00-00-000Z.md');
80+
});
81+
82+
it('should return null when briefings directory does not exist', () => {
83+
vi.mocked(existsSync).mockReturnValue(false);
84+
85+
const result = service.findMostRecentBriefing();
86+
expect(result).toBeNull();
87+
});
88+
89+
it('should return null when directory is empty', () => {
90+
vi.mocked(existsSync).mockReturnValue(true);
91+
vi.mocked(readdirSync).mockReturnValue([]);
92+
93+
const result = service.findMostRecentBriefing();
94+
expect(result).toBeNull();
95+
});
96+
97+
it('should filter out non-markdown files', () => {
98+
vi.mocked(existsSync).mockReturnValue(true);
99+
vi.mocked(readdirSync).mockReturnValue([
100+
'.gitkeep',
101+
'2026-04-01T10-00-00-000Z.md',
102+
'README.txt',
103+
] as unknown as ReturnType<typeof readdirSync>);
104+
105+
const result = service.findMostRecentBriefing();
106+
expect(result).toContain('2026-04-01T10-00-00-000Z.md');
107+
});
108+
});
109+
110+
describe('parseBriefingMarkdown', () => {
111+
it('should extract title from briefing', () => {
112+
const result = service.parseBriefingMarkdown(SAMPLE_BRIEFING);
113+
expect(result.title).toBe('Auth Feature');
114+
});
115+
116+
it('should extract mode from briefing', () => {
117+
const result = service.parseBriefingMarkdown(SAMPLE_BRIEFING);
118+
expect(result.mode).toBe('ACT');
119+
});
120+
121+
it('should extract decisions from briefing', () => {
122+
const result = service.parseBriefingMarkdown(SAMPLE_BRIEFING);
123+
expect(result.decisions).toEqual([
124+
'Use JWT for authentication',
125+
'Store tokens in httpOnly cookies',
126+
]);
127+
});
128+
129+
it('should extract pending tasks from briefing', () => {
130+
const result = service.parseBriefingMarkdown(SAMPLE_BRIEFING);
131+
expect(result.pendingTasks).toEqual(['Add refresh token logic', 'Write integration tests']);
132+
});
133+
134+
it('should extract changed files from briefing', () => {
135+
const result = service.parseBriefingMarkdown(SAMPLE_BRIEFING);
136+
expect(result.changedFiles).toEqual([
137+
'src/auth/auth.service.ts',
138+
'src/auth/auth.controller.ts',
139+
]);
140+
});
141+
142+
it('should extract resume command from briefing', () => {
143+
const result = service.parseBriefingMarkdown(SAMPLE_BRIEFING);
144+
expect(result.resumeCommand).toBe('ACT continue implementation of "Auth Feature"');
145+
});
146+
147+
it('should handle briefing with no decisions', () => {
148+
const md = `# Briefing: Test
149+
150+
**Mode**: PLAN
151+
**Created**: 2026-04-01T00:00:00.000Z
152+
153+
## Decisions
154+
_No decisions recorded._
155+
156+
## Changed Files
157+
_No files changed._
158+
159+
## Pending Tasks
160+
_No pending tasks._
161+
162+
## Resume Command
163+
\`\`\`
164+
PLAN continue "Test"
165+
\`\`\`
166+
`;
167+
const result = service.parseBriefingMarkdown(md);
168+
expect(result.decisions).toEqual([]);
169+
expect(result.changedFiles).toEqual([]);
170+
expect(result.pendingTasks).toEqual([]);
171+
});
172+
173+
it('should handle empty content gracefully', () => {
174+
const result = service.parseBriefingMarkdown('');
175+
expect(result.title).toBe('Untitled');
176+
expect(result.decisions).toEqual([]);
177+
expect(result.pendingTasks).toEqual([]);
178+
expect(result.changedFiles).toEqual([]);
179+
expect(result.resumeCommand).toBe('');
180+
});
181+
});
182+
183+
describe('loadBriefing', () => {
184+
it('should load briefing from explicit path', async () => {
185+
vi.mocked(existsSync).mockReturnValue(true);
186+
vi.mocked(fs.readFile).mockResolvedValue(SAMPLE_BRIEFING);
187+
188+
const result = await service.loadBriefing(
189+
'/tmp/test-project/docs/codingbuddy/briefings/test.md',
190+
);
191+
192+
expect(result.title).toBe('Auth Feature');
193+
expect(result.decisions).toHaveLength(2);
194+
expect(fs.readFile).toHaveBeenCalledWith(
195+
'/tmp/test-project/docs/codingbuddy/briefings/test.md',
196+
'utf-8',
197+
);
198+
});
199+
200+
it('should load most recent briefing when no path specified', async () => {
201+
vi.mocked(existsSync).mockReturnValue(true);
202+
vi.mocked(readdirSync).mockReturnValue([
203+
'2026-04-01T10-00-00-000Z.md',
204+
] as unknown as ReturnType<typeof readdirSync>);
205+
vi.mocked(fs.readFile).mockResolvedValue(SAMPLE_BRIEFING);
206+
207+
const result = await service.loadBriefing();
208+
209+
expect(result.title).toBe('Auth Feature');
210+
});
211+
212+
it('should throw when no briefings exist and no path given', async () => {
213+
vi.mocked(existsSync).mockReturnValue(false);
214+
215+
await expect(service.loadBriefing()).rejects.toThrow('No briefing files found');
216+
});
217+
218+
it('should throw when specified file does not exist', async () => {
219+
vi.mocked(existsSync).mockReturnValue(false);
220+
221+
await expect(service.loadBriefing('/nonexistent/path.md')).rejects.toThrow(
222+
'Briefing file not found',
223+
);
224+
});
225+
});
226+
227+
describe('restoreContext', () => {
228+
it('should call resetContext with parsed briefing data', async () => {
229+
vi.mocked(existsSync).mockReturnValue(true);
230+
vi.mocked(readdirSync).mockReturnValue([
231+
'2026-04-01T10-00-00-000Z.md',
232+
] as unknown as ReturnType<typeof readdirSync>);
233+
vi.mocked(fs.readFile).mockResolvedValue(SAMPLE_BRIEFING);
234+
235+
await service.restoreContext();
236+
237+
expect(mockContextDocService.resetContext).toHaveBeenCalledWith(
238+
expect.objectContaining({
239+
title: 'Auth Feature',
240+
decisions: ['Use JWT for authentication', 'Store tokens in httpOnly cookies'],
241+
notes: ['Add refresh token logic', 'Write integration tests'],
242+
}),
243+
);
244+
});
245+
246+
it('should return briefing summary after restore', async () => {
247+
vi.mocked(existsSync).mockReturnValue(true);
248+
vi.mocked(readdirSync).mockReturnValue([
249+
'2026-04-01T10-00-00-000Z.md',
250+
] as unknown as ReturnType<typeof readdirSync>);
251+
vi.mocked(fs.readFile).mockResolvedValue(SAMPLE_BRIEFING);
252+
253+
const result = await service.restoreContext();
254+
255+
expect(result.title).toBe('Auth Feature');
256+
expect(result.resumeCommand).toBe('ACT continue implementation of "Auth Feature"');
257+
expect(result.contextRestored).toBe(true);
258+
});
259+
260+
it('should accept explicit briefing path', async () => {
261+
vi.mocked(existsSync).mockReturnValue(true);
262+
vi.mocked(fs.readFile).mockResolvedValue(SAMPLE_BRIEFING);
263+
264+
const result = await service.restoreContext('/tmp/briefing.md');
265+
266+
expect(result.contextRestored).toBe(true);
267+
});
268+
});
269+
});

0 commit comments

Comments
 (0)