sofia-cli 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +42 -20
  2. package/dist/infra/deploy.sh +193 -0
  3. package/dist/infra/gather-env.sh +211 -0
  4. package/dist/infra/infra/deploy.sh +193 -0
  5. package/dist/infra/infra/gather-env.sh +211 -0
  6. package/dist/infra/infra/main.bicep +90 -0
  7. package/dist/infra/infra/main.bicepparam +18 -0
  8. package/dist/infra/infra/resources.bicep +134 -0
  9. package/dist/infra/infra/teardown.sh +114 -0
  10. package/dist/infra/main.bicep +90 -0
  11. package/dist/infra/main.bicepparam +18 -0
  12. package/dist/infra/resources.bicep +134 -0
  13. package/dist/infra/teardown.sh +114 -0
  14. package/dist/src/cli/developCommand.js +0 -2
  15. package/dist/src/cli/index.js +8 -1
  16. package/dist/src/cli/workshopCommand.js +1 -1
  17. package/dist/src/develop/index.js +1 -1
  18. package/dist/src/develop/pocUtils.js +228 -0
  19. package/dist/src/develop/ralphLoop.js +3 -3
  20. package/dist/src/shared/data/cards.json +655 -670
  21. package/docs/architecture.md +2 -1
  22. package/package.json +5 -3
  23. package/src/cli/developCommand.ts +1 -3
  24. package/src/cli/index.ts +11 -1
  25. package/src/cli/workshopCommand.ts +21 -17
  26. package/src/develop/dynamicScaffolder.ts +36 -30
  27. package/src/develop/index.ts +13 -2
  28. package/src/develop/pocUtils.ts +296 -0
  29. package/src/develop/ralphLoop.ts +8 -28
  30. package/src/develop/templateRegistry.ts +19 -18
  31. package/src/shared/data/cards.json +655 -670
  32. package/tests/e2e/developE2e.spec.ts +3 -61
  33. package/tests/e2e/developFailureE2e.spec.ts +34 -38
  34. package/tests/integration/pocGithubMcp.spec.ts +29 -39
  35. package/tests/integration/pocLocalFallback.spec.ts +29 -39
  36. package/tests/integration/ralphLoopFlow.spec.ts +46 -66
  37. package/tests/integration/ralphLoopPartial.spec.ts +30 -37
  38. package/tests/unit/develop/githubMcpAdapter.spec.ts +0 -134
  39. package/tests/unit/develop/outputValidator.spec.ts +45 -21
  40. package/tests/unit/develop/ralphLoop.spec.ts +58 -94
  41. package/tsconfig.json +2 -1
  42. package/vitest.workspace.ts +5 -0
  43. package/dist/src/develop/pocScaffolder.js +0 -542
  44. package/dist/tests/e2e/developE2e.spec.js +0 -126
  45. package/dist/tests/e2e/developFailureE2e.spec.js +0 -247
  46. package/dist/tests/e2e/developPty.spec.js +0 -75
  47. package/dist/tests/e2e/discoveryWebSearchRelevance.spec.js +0 -84
  48. package/dist/tests/e2e/harness.spec.js +0 -83
  49. package/dist/tests/e2e/mcpLive.spec.js +0 -120
  50. package/dist/tests/e2e/newSession.e2e.spec.js +0 -177
  51. package/dist/tests/e2e/ralphLoopEnrichmentComparison.spec.js +0 -62
  52. package/dist/tests/e2e/workiqEnrichment.spec.js +0 -56
  53. package/dist/tests/e2e/zavaSimulation.spec.js +0 -452
  54. package/dist/tests/fixtures/test-fixture-project/src/add.js +0 -3
  55. package/dist/tests/fixtures/test-fixture-project/tests/failing.test.js +0 -6
  56. package/dist/tests/fixtures/test-fixture-project/tests/hanging.test.js +0 -8
  57. package/dist/tests/fixtures/test-fixture-project/tests/passing.test.js +0 -10
  58. package/dist/tests/fixtures/test-fixture-project/vitest.config.js +0 -6
  59. package/dist/tests/integration/autoStartConversation.spec.js +0 -138
  60. package/dist/tests/integration/defaultCommand.spec.js +0 -147
  61. package/dist/tests/integration/directCommandNonTty.spec.js +0 -224
  62. package/dist/tests/integration/directCommandTty.spec.js +0 -151
  63. package/dist/tests/integration/discoveryEnrichmentFlow.spec.js +0 -175
  64. package/dist/tests/integration/exportArtifacts.spec.js +0 -202
  65. package/dist/tests/integration/exportFallbackFlow.spec.js +0 -99
  66. package/dist/tests/integration/mcpDegradationFlow.spec.js +0 -190
  67. package/dist/tests/integration/mcpTransportFlow.spec.js +0 -139
  68. package/dist/tests/integration/newSessionFlow.spec.js +0 -343
  69. package/dist/tests/integration/pocGithubMcp.spec.js +0 -186
  70. package/dist/tests/integration/pocLocalFallback.spec.js +0 -171
  71. package/dist/tests/integration/pocScaffold.spec.js +0 -163
  72. package/dist/tests/integration/ralphLoopFlow.spec.js +0 -359
  73. package/dist/tests/integration/ralphLoopPartial.spec.js +0 -368
  74. package/dist/tests/integration/resumeAndBacktrack.spec.js +0 -247
  75. package/dist/tests/integration/spinnerLifecycle.spec.js +0 -220
  76. package/dist/tests/integration/summarizationFlow.spec.js +0 -115
  77. package/dist/tests/integration/testRunnerReal.spec.js +0 -52
  78. package/dist/tests/integration/webSearchAgent.spec.js +0 -128
  79. package/dist/tests/live/copilotSdkLive.spec.js +0 -107
  80. package/dist/tests/live/zavaFullWorkshop.spec.js +0 -392
  81. package/dist/tests/setup/loadEnv.js +0 -3
  82. package/dist/tests/unit/cli/developCommand.spec.js +0 -567
  83. package/dist/tests/unit/cli/directCommands.spec.js +0 -279
  84. package/dist/tests/unit/cli/envLoader.spec.js +0 -58
  85. package/dist/tests/unit/cli/ioContext.spec.js +0 -119
  86. package/dist/tests/unit/cli/preflight.spec.js +0 -108
  87. package/dist/tests/unit/cli/statusCommand.spec.js +0 -111
  88. package/dist/tests/unit/cli/workshopClientFallback.spec.js +0 -80
  89. package/dist/tests/unit/cli/workshopCommand.spec.js +0 -329
  90. package/dist/tests/unit/config/vitestEnvSetup.spec.js +0 -13
  91. package/dist/tests/unit/develop/checkpointState.spec.js +0 -315
  92. package/dist/tests/unit/develop/codeGenerator.spec.js +0 -355
  93. package/dist/tests/unit/develop/githubMcpAdapter.spec.js +0 -231
  94. package/dist/tests/unit/develop/mcpContextEnricher.spec.js +0 -433
  95. package/dist/tests/unit/develop/outputValidator.spec.js +0 -119
  96. package/dist/tests/unit/develop/pocScaffolder.spec.js +0 -353
  97. package/dist/tests/unit/develop/ralphLoop.spec.js +0 -1248
  98. package/dist/tests/unit/develop/templateRegistry.spec.js +0 -85
  99. package/dist/tests/unit/develop/testRunner.spec.js +0 -249
  100. package/dist/tests/unit/infraBicep.spec.js +0 -92
  101. package/dist/tests/unit/infraDeploy.spec.js +0 -82
  102. package/dist/tests/unit/infraTeardown.spec.js +0 -63
  103. package/dist/tests/unit/logging/logger.spec.js +0 -43
  104. package/dist/tests/unit/loop/conversationLoop.spec.js +0 -592
  105. package/dist/tests/unit/loop/phaseSummarizer.spec.js +0 -141
  106. package/dist/tests/unit/loop/streamingMarkdown.spec.js +0 -147
  107. package/dist/tests/unit/mcp/mcpManager.spec.js +0 -279
  108. package/dist/tests/unit/mcp/mcpTransport.spec.js +0 -529
  109. package/dist/tests/unit/mcp/retryPolicy.spec.js +0 -218
  110. package/dist/tests/unit/mcp/timeoutValidation.spec.js +0 -46
  111. package/dist/tests/unit/mcp/webSearch.spec.js +0 -567
  112. package/dist/tests/unit/phases/contextSummarizer.spec.js +0 -140
  113. package/dist/tests/unit/phases/discoveryEnricher.repeatCalls.spec.js +0 -93
  114. package/dist/tests/unit/phases/discoveryEnricher.spec.js +0 -411
  115. package/dist/tests/unit/phases/phaseExtractors.spec.js +0 -352
  116. package/dist/tests/unit/phases/phaseHandlers.spec.js +0 -425
  117. package/dist/tests/unit/prompts/promptLoader.spec.js +0 -118
  118. package/dist/tests/unit/schemas/pocSchemas.spec.js +0 -412
  119. package/dist/tests/unit/schemas/session.spec.js +0 -257
  120. package/dist/tests/unit/sessions/exportPaths.spec.js +0 -31
  121. package/dist/tests/unit/sessions/exportWriter.spec.js +0 -655
  122. package/dist/tests/unit/sessions/sessionManager.spec.js +0 -151
  123. package/dist/tests/unit/sessions/sessionStore.spec.js +0 -116
  124. package/dist/tests/unit/shared/activitySpinner.spec.js +0 -175
  125. package/dist/tests/unit/shared/cardsLoader.spec.js +0 -76
  126. package/dist/tests/unit/shared/copilotClient.spec.js +0 -155
  127. package/dist/tests/unit/shared/errorClassifier.spec.js +0 -131
  128. package/dist/tests/unit/shared/events.spec.js +0 -55
  129. package/dist/tests/unit/shared/markdownRenderer.spec.js +0 -35
  130. package/dist/tests/unit/shared/markdownRendererChunks.spec.js +0 -70
  131. package/dist/tests/unit/shared/tableRenderer.spec.js +0 -34
  132. package/dist/vitest.config.js +0 -14
  133. package/dist/vitest.live.config.js +0 -18
  134. package/src/develop/pocScaffolder.ts +0 -646
  135. package/tests/integration/pocScaffold.spec.ts +0 -220
  136. package/tests/unit/develop/pocScaffolder.spec.ts +0 -451
@@ -1,592 +0,0 @@
1
- /**
2
- * ConversationLoop tests.
3
- *
4
- * Validates the multi-turn conversation orchestration, streaming render,
5
- * event dispatching, phase handling, and shutdown behavior.
6
- */
7
- import { describe, it, expect, vi, beforeEach } from 'vitest';
8
- import { ConversationLoop, } from '../../../src/loop/conversationLoop.js';
9
- import { createFakeCopilotClient } from '../../../src/shared/copilotClient.js';
10
- // ── Helpers ─────────────────────────────────────────────────────────────────
11
- function makeSession(overrides) {
12
- return {
13
- sessionId: 'test-session-1',
14
- schemaVersion: '1.0.0',
15
- createdAt: '2025-01-01T00:00:00Z',
16
- updatedAt: '2025-01-01T00:00:00Z',
17
- phase: 'Discover',
18
- status: 'Active',
19
- participants: [{ id: 'p1', displayName: 'Alice', role: 'Facilitator' }],
20
- artifacts: { generatedFiles: [] },
21
- ...overrides,
22
- };
23
- }
24
- /** Create a LoopIO that feeds predetermined inputs then returns null. */
25
- function makeIO(inputs, opts) {
26
- let inputIndex = 0;
27
- const written = [];
28
- const activities = [];
29
- return {
30
- write(text) {
31
- written.push(text);
32
- },
33
- writeActivity(text) {
34
- activities.push(text);
35
- },
36
- writeToolSummary(_toolName, _summary) {
37
- // no-op for tests
38
- },
39
- async readInput(_prompt) {
40
- if (inputIndex >= inputs.length)
41
- return null;
42
- return inputs[inputIndex++] ?? null;
43
- },
44
- async showDecisionGate(_phase) {
45
- return { choice: 'continue' };
46
- },
47
- isJsonMode: opts?.json ?? false,
48
- isTTY: opts?.tty ?? true,
49
- // Expose captured output for assertions
50
- get _written() {
51
- return written;
52
- },
53
- get _activities() {
54
- return activities;
55
- },
56
- };
57
- }
58
- function makePhaseHandler(overrides) {
59
- return {
60
- phase: 'Discover',
61
- buildSystemPrompt: () => 'You are a workshop facilitator.',
62
- extractResult: (_session) => ({}),
63
- ...overrides,
64
- };
65
- }
66
- // ── Tests ────────────────────────────────────────────────────────────────────
67
- describe('ConversationLoop', () => {
68
- beforeEach(() => {
69
- // Remove any leftover SIGINT listeners from previous tests
70
- process.removeAllListeners('SIGINT');
71
- });
72
- describe('basic conversation flow', () => {
73
- it('sends user input to LLM and accumulates turns', async () => {
74
- const client = createFakeCopilotClient([
75
- { role: 'assistant', content: 'Tell me about your business.' },
76
- { role: 'assistant', content: 'Great, let us proceed.' },
77
- ]);
78
- const io = makeIO(['We sell widgets', 'We have 50 employees']);
79
- const handler = makePhaseHandler();
80
- const session = makeSession();
81
- const loop = new ConversationLoop({
82
- client,
83
- io,
84
- session,
85
- phaseHandler: handler,
86
- });
87
- const result = await loop.run();
88
- // Should have 4 turns: 2 user + 2 assistant
89
- expect(result.turns).toBeDefined();
90
- expect(result.turns.length).toBe(4);
91
- expect(result.turns[0].role).toBe('user');
92
- expect(result.turns[0].content).toBe('We sell widgets');
93
- expect(result.turns[1].role).toBe('assistant');
94
- expect(result.turns[1].content).toBe('Tell me about your business.');
95
- expect(result.turns[2].role).toBe('user');
96
- expect(result.turns[2].content).toBe('We have 50 employees');
97
- expect(result.turns[3].role).toBe('assistant');
98
- expect(result.turns[3].content).toBe('Great, let us proceed.');
99
- });
100
- it('terminates on null input (EOF/Ctrl+D)', async () => {
101
- const client = createFakeCopilotClient([]);
102
- const io = makeIO([null]);
103
- const handler = makePhaseHandler();
104
- const loop = new ConversationLoop({
105
- client,
106
- io,
107
- session: makeSession(),
108
- phaseHandler: handler,
109
- });
110
- const result = await loop.run();
111
- expect(result.turns ?? []).toHaveLength(0);
112
- });
113
- it('updates session after each turn via onSessionUpdate callback', async () => {
114
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'Response one' }]);
115
- const io = makeIO(['hello']);
116
- const updates = [];
117
- const onSessionUpdate = vi.fn(async (s) => {
118
- updates.push({ ...s });
119
- });
120
- const loop = new ConversationLoop({
121
- client,
122
- io,
123
- session: makeSession(),
124
- phaseHandler: makePhaseHandler(),
125
- onSessionUpdate,
126
- });
127
- await loop.run();
128
- expect(onSessionUpdate).toHaveBeenCalledTimes(1);
129
- expect(updates[0].turns).toHaveLength(2);
130
- });
131
- });
132
- describe('event dispatching', () => {
133
- it('emits events for TextDelta and Activity', async () => {
134
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'Hello!' }]);
135
- const io = makeIO(['hi']);
136
- const events = [];
137
- const loop = new ConversationLoop({
138
- client,
139
- io,
140
- session: makeSession(),
141
- phaseHandler: makePhaseHandler(),
142
- onEvent: (e) => events.push(e),
143
- });
144
- await loop.run();
145
- // Should have at least: Activity (starting phase) + TextDelta
146
- const activityEvents = events.filter((e) => e.type === 'Activity');
147
- const textEvents = events.filter((e) => e.type === 'TextDelta');
148
- expect(activityEvents.length).toBeGreaterThanOrEqual(1);
149
- expect(textEvents.length).toBe(1);
150
- expect(textEvents[0].type === 'TextDelta' && textEvents[0].text).toBe('Hello!');
151
- });
152
- });
153
- describe('streaming output', () => {
154
- it('writes streamed text to io.write in TTY mode', async () => {
155
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'Streaming content' }]);
156
- const io = makeIO(['go'], { tty: true, json: false });
157
- const loop = new ConversationLoop({
158
- client,
159
- io,
160
- session: makeSession(),
161
- phaseHandler: makePhaseHandler(),
162
- });
163
- await loop.run();
164
- const ioAny = io;
165
- // In TTY mode, text goes through renderMarkdown which may add formatting
166
- const allOutput = ioAny._written.join('');
167
- expect(allOutput).toContain('Streaming content');
168
- });
169
- it('outputs JSON envelope in JSON mode', async () => {
170
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'Result text' }]);
171
- const io = makeIO(['go'], { json: true });
172
- const loop = new ConversationLoop({
173
- client,
174
- io,
175
- session: makeSession(),
176
- phaseHandler: makePhaseHandler(),
177
- });
178
- await loop.run();
179
- const ioAny = io;
180
- const jsonOutputs = ioAny._written.filter((w) => w.startsWith('{'));
181
- expect(jsonOutputs.length).toBeGreaterThanOrEqual(1);
182
- const parsed = JSON.parse(jsonOutputs[0]);
183
- expect(parsed.phase).toBe('Discover');
184
- expect(parsed.content).toBe('Result text');
185
- });
186
- });
187
- describe('phase handler integration', () => {
188
- it('applies extractResult updates to session', async () => {
189
- const client = createFakeCopilotClient([
190
- { role: 'assistant', content: 'We identified your challenges' },
191
- ]);
192
- const io = makeIO(['Our business sells widgets']);
193
- const handler = makePhaseHandler({
194
- extractResult: (_session, _response) => ({
195
- businessContext: {
196
- businessDescription: 'Widget seller',
197
- challenges: ['Growth'],
198
- },
199
- }),
200
- });
201
- const loop = new ConversationLoop({
202
- client,
203
- io,
204
- session: makeSession(),
205
- phaseHandler: handler,
206
- });
207
- const result = await loop.run();
208
- expect(result.businessContext).toBeDefined();
209
- expect(result.businessContext.businessDescription).toBe('Widget seller');
210
- expect(result.businessContext.challenges).toEqual(['Growth']);
211
- });
212
- it('uses system prompt from handler when creating session', async () => {
213
- const createSessionSpy = vi.fn();
214
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'ok' }]);
215
- // Spy on createSession
216
- const originalCreateSession = client.createSession.bind(client);
217
- client.createSession = async (opts) => {
218
- createSessionSpy(opts);
219
- return originalCreateSession(opts);
220
- };
221
- const io = makeIO(['test']);
222
- const handler = makePhaseHandler({
223
- buildSystemPrompt: () => 'Custom system prompt for discover',
224
- });
225
- const loop = new ConversationLoop({
226
- client,
227
- io,
228
- session: makeSession(),
229
- phaseHandler: handler,
230
- });
231
- await loop.run();
232
- expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({
233
- systemPrompt: expect.stringContaining('Custom system prompt for discover'),
234
- }));
235
- });
236
- it('includes references from handler in session options', async () => {
237
- const createSessionSpy = vi.fn();
238
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'ok' }]);
239
- const originalCreateSession = client.createSession.bind(client);
240
- client.createSession = async (opts) => {
241
- createSessionSpy(opts);
242
- return originalCreateSession(opts);
243
- };
244
- const io = makeIO(['test']);
245
- const handler = makePhaseHandler({
246
- getReferences: () => ['doc1.md', 'doc2.md'],
247
- });
248
- const loop = new ConversationLoop({
249
- client,
250
- io,
251
- session: makeSession(),
252
- phaseHandler: handler,
253
- });
254
- await loop.run();
255
- expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({
256
- references: ['doc1.md', 'doc2.md'],
257
- }));
258
- });
259
- });
260
- describe('phase completion', () => {
261
- it('does not break loop on empty input when isComplete returns false', async () => {
262
- const client = createFakeCopilotClient([
263
- { role: 'assistant', content: 'Need more info' },
264
- { role: 'assistant', content: 'Thanks' },
265
- ]);
266
- let callCount = 0;
267
- const io = makeIO(['', 'more data']);
268
- const handler = makePhaseHandler({
269
- isComplete: () => {
270
- callCount++;
271
- // First call: not complete; won't be called again because second input is non-empty
272
- return callCount > 1;
273
- },
274
- });
275
- const loop = new ConversationLoop({
276
- client,
277
- io,
278
- session: makeSession(),
279
- phaseHandler: handler,
280
- });
281
- const result = await loop.run();
282
- // Both inputs should produce turns (empty string still gets sent, "more data" also)
283
- expect(result.turns.length).toBeGreaterThanOrEqual(2);
284
- });
285
- });
286
- describe('session state', () => {
287
- it('getSession returns a copy of current session', async () => {
288
- const client = createFakeCopilotClient([]);
289
- const io = makeIO([null]);
290
- const session = makeSession();
291
- const loop = new ConversationLoop({
292
- client,
293
- io,
294
- session,
295
- phaseHandler: makePhaseHandler(),
296
- });
297
- const before = loop.getSession();
298
- expect(before.sessionId).toBe('test-session-1');
299
- // Mutate the returned copy
300
- before.sessionId = 'mutated';
301
- // Original should be unchanged
302
- expect(loop.getSession().sessionId).toBe('test-session-1');
303
- });
304
- it('updates updatedAt timestamp after each turn', async () => {
305
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'ok' }]);
306
- const io = makeIO(['hello']);
307
- const loop = new ConversationLoop({
308
- client,
309
- io,
310
- session: makeSession({ updatedAt: '2025-01-01T00:00:00Z' }),
311
- phaseHandler: makePhaseHandler(),
312
- });
313
- const result = await loop.run();
314
- expect(result.updatedAt).not.toBe('2025-01-01T00:00:00Z');
315
- });
316
- });
317
- describe('edge cases', () => {
318
- it('handles handler with no getReferences gracefully', async () => {
319
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'ok' }]);
320
- const io = makeIO(['test']);
321
- const handler = makePhaseHandler();
322
- delete handler.getReferences;
323
- const loop = new ConversationLoop({
324
- client,
325
- io,
326
- session: makeSession(),
327
- phaseHandler: handler,
328
- });
329
- // Should not throw
330
- const result = await loop.run();
331
- expect(result.turns).toHaveLength(2);
332
- });
333
- it('handles exhausted fake responses gracefully', async () => {
334
- // Only 1 response configured but 2 messages sent
335
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'First' }]);
336
- const io = makeIO(['msg1', 'msg2']);
337
- const loop = new ConversationLoop({
338
- client,
339
- io,
340
- session: makeSession(),
341
- phaseHandler: makePhaseHandler(),
342
- });
343
- const result = await loop.run();
344
- expect(result.turns).toHaveLength(4); // 2 user + 2 assistant
345
- expect(result.turns[3].content).toContain('No more responses');
346
- });
347
- });
348
- // ── T073: Auto-start behavior ──────────────────────────────────────────
349
- describe('auto-start with initialMessage (T073)', () => {
350
- it('sends initialMessage to LLM before readInput()', async () => {
351
- const client = createFakeCopilotClient([
352
- { role: 'assistant', content: 'Welcome to the Discover phase!' },
353
- { role: 'assistant', content: 'Great, thanks for that info.' },
354
- ]);
355
- const readInputCalls = [];
356
- const io = makeIO(['user says hello']);
357
- const origReadInput = io.readInput.bind(io);
358
- io.readInput = async (prompt) => {
359
- readInputCalls.push(prompt ?? '');
360
- return origReadInput(prompt);
361
- };
362
- const loop = new ConversationLoop({
363
- client,
364
- io,
365
- session: makeSession(),
366
- phaseHandler: makePhaseHandler(),
367
- initialMessage: 'Introduce the Discover phase and ask the first question.',
368
- });
369
- const result = await loop.run();
370
- // Initial message turn + user turn = 4 turns total
371
- expect(result.turns).toHaveLength(4);
372
- // First turn pair: system initial message → LLM greeting
373
- expect(result.turns[0].role).toBe('user');
374
- expect(result.turns[0].content).toBe('Introduce the Discover phase and ask the first question.');
375
- expect(result.turns[1].role).toBe('assistant');
376
- expect(result.turns[1].content).toBe('Welcome to the Discover phase!');
377
- });
378
- it('streams the greeting response to output', async () => {
379
- const client = createFakeCopilotClient([
380
- { role: 'assistant', content: 'Hello! Welcome to sofIA.' },
381
- ]);
382
- const io = makeIO([], { tty: true, json: false });
383
- const loop = new ConversationLoop({
384
- client,
385
- io,
386
- session: makeSession(),
387
- phaseHandler: makePhaseHandler(),
388
- initialMessage: 'Start the phase.',
389
- });
390
- await loop.run();
391
- const ioTyped = io;
392
- const allOutput = ioTyped._written.join('');
393
- expect(allOutput).toContain('Hello! Welcome to sofIA.');
394
- });
395
- it('records initial exchange in turn history', async () => {
396
- const client = createFakeCopilotClient([
397
- { role: 'assistant', content: 'Phase intro response' },
398
- ]);
399
- const io = makeIO([]);
400
- const loop = new ConversationLoop({
401
- client,
402
- io,
403
- session: makeSession(),
404
- phaseHandler: makePhaseHandler(),
405
- initialMessage: 'Auto-start message',
406
- });
407
- const result = await loop.run();
408
- expect(result.turns).toHaveLength(2);
409
- expect(result.turns[0].role).toBe('user');
410
- expect(result.turns[0].content).toBe('Auto-start message');
411
- expect(result.turns[1].role).toBe('assistant');
412
- expect(result.turns[1].content).toBe('Phase intro response');
413
- });
414
- it('does NOT auto-start when initialMessage is not provided', async () => {
415
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'Response' }]);
416
- const io = makeIO(['user input']);
417
- const loop = new ConversationLoop({
418
- client,
419
- io,
420
- session: makeSession(),
421
- phaseHandler: makePhaseHandler(),
422
- // No initialMessage
423
- });
424
- const result = await loop.run();
425
- // Only user + assistant turns, no initial message turn
426
- expect(result.turns).toHaveLength(2);
427
- expect(result.turns[0].role).toBe('user');
428
- expect(result.turns[0].content).toBe('user input');
429
- });
430
- });
431
- // ── Session resume: conversation history in system prompt ────────────────
432
- describe('session resume with prior turns', () => {
433
- it('injects prior conversation history into the system prompt on resume', async () => {
434
- const createSessionSpy = vi.fn();
435
- const client = createFakeCopilotClient([
436
- { role: 'assistant', content: 'Welcome back! You told me about widgets.' },
437
- ]);
438
- const originalCreateSession = client.createSession.bind(client);
439
- client.createSession = async (opts) => {
440
- createSessionSpy(opts);
441
- return originalCreateSession(opts);
442
- };
443
- // Session with existing turns from a prior Discover conversation
444
- const session = makeSession({
445
- turns: [
446
- {
447
- phase: 'Discover',
448
- sequence: 1,
449
- role: 'user',
450
- content: 'We sell widgets worldwide',
451
- timestamp: '2025-01-01T00:00:00Z',
452
- },
453
- {
454
- phase: 'Discover',
455
- sequence: 2,
456
- role: 'assistant',
457
- content: 'Great, what are your main challenges?',
458
- timestamp: '2025-01-01T00:01:00Z',
459
- },
460
- ],
461
- });
462
- const io = makeIO([]);
463
- const handler = makePhaseHandler({
464
- buildSystemPrompt: () => 'You are a workshop facilitator.',
465
- });
466
- const loop = new ConversationLoop({
467
- client,
468
- io,
469
- session,
470
- phaseHandler: handler,
471
- initialMessage: 'We are resuming. Summarize progress.',
472
- });
473
- await loop.run();
474
- // The system prompt should contain the prior conversation history
475
- const passedOpts = createSessionSpy.mock.calls[0][0];
476
- expect(passedOpts.systemPrompt).toContain('We sell widgets worldwide');
477
- expect(passedOpts.systemPrompt).toContain('Great, what are your main challenges?');
478
- expect(passedOpts.systemPrompt).toContain('Previous conversation');
479
- });
480
- it('does NOT inject history when no prior turns exist', async () => {
481
- const createSessionSpy = vi.fn();
482
- const client = createFakeCopilotClient([
483
- { role: 'assistant', content: 'Welcome to the workshop!' },
484
- ]);
485
- const originalCreateSession = client.createSession.bind(client);
486
- client.createSession = async (opts) => {
487
- createSessionSpy(opts);
488
- return originalCreateSession(opts);
489
- };
490
- const io = makeIO([]);
491
- const handler = makePhaseHandler({
492
- buildSystemPrompt: () => 'You are a workshop facilitator.',
493
- });
494
- const loop = new ConversationLoop({
495
- client,
496
- io,
497
- session: makeSession(), // No turns
498
- phaseHandler: handler,
499
- initialMessage: 'Start the Discover phase.',
500
- });
501
- await loop.run();
502
- const passedOpts = createSessionSpy.mock.calls[0][0];
503
- // System prompt should contain what the handler returned plus phase boundary
504
- expect(passedOpts.systemPrompt).toContain('You are a workshop facilitator.');
505
- });
506
- it('only includes turns for the current phase in the history', async () => {
507
- const createSessionSpy = vi.fn();
508
- const client = createFakeCopilotClient([
509
- { role: 'assistant', content: 'Resuming ideation.' },
510
- ]);
511
- const originalCreateSession = client.createSession.bind(client);
512
- client.createSession = async (opts) => {
513
- createSessionSpy(opts);
514
- return originalCreateSession(opts);
515
- };
516
- const session = makeSession({
517
- phase: 'Ideate',
518
- turns: [
519
- {
520
- phase: 'Discover',
521
- sequence: 1,
522
- role: 'user',
523
- content: 'Discovery message (should NOT appear)',
524
- timestamp: '2025-01-01T00:00:00Z',
525
- },
526
- {
527
- phase: 'Ideate',
528
- sequence: 2,
529
- role: 'user',
530
- content: 'Ideation message (should appear)',
531
- timestamp: '2025-01-01T01:00:00Z',
532
- },
533
- {
534
- phase: 'Ideate',
535
- sequence: 3,
536
- role: 'assistant',
537
- content: 'Ideation response (should appear)',
538
- timestamp: '2025-01-01T01:01:00Z',
539
- },
540
- ],
541
- });
542
- const io = makeIO([]);
543
- const handler = makePhaseHandler({
544
- phase: 'Ideate',
545
- buildSystemPrompt: () => 'Ideation facilitator.',
546
- });
547
- const loop = new ConversationLoop({
548
- client,
549
- io,
550
- session,
551
- phaseHandler: handler,
552
- initialMessage: 'Resume ideation.',
553
- });
554
- await loop.run();
555
- const passedOpts = createSessionSpy.mock.calls[0][0];
556
- expect(passedOpts.systemPrompt).toContain('Ideation message (should appear)');
557
- expect(passedOpts.systemPrompt).toContain('Ideation response (should appear)');
558
- expect(passedOpts.systemPrompt).not.toContain('Discovery message (should NOT appear)');
559
- });
560
- });
561
- // ── T055: SessionOptions.onUsage callback ─────────────────────────────────
562
- describe('SessionOptions.onUsage (T055)', () => {
563
- it('accepts an onUsage callback on SessionOptions', () => {
564
- const opts = {
565
- systemPrompt: 'Test',
566
- onUsage: vi.fn(),
567
- };
568
- expect(opts.onUsage).toBeDefined();
569
- expect(typeof opts.onUsage).toBe('function');
570
- });
571
- it('onUsage callback is forwarded when passed through createSession', async () => {
572
- const usageCb = vi.fn();
573
- const createSessionSpy = vi.fn();
574
- const client = createFakeCopilotClient([{ role: 'assistant', content: 'OK' }]);
575
- const originalCreateSession = client.createSession.bind(client);
576
- client.createSession = async (opts) => {
577
- createSessionSpy(opts);
578
- return originalCreateSession(opts);
579
- };
580
- await client.createSession({
581
- systemPrompt: 'Test',
582
- onUsage: usageCb,
583
- });
584
- const passedOpts = createSessionSpy.mock.calls[0][0];
585
- expect(passedOpts.onUsage).toBe(usageCb);
586
- });
587
- it('omitting onUsage does not set it on SessionOptions', () => {
588
- const opts = { systemPrompt: 'Test' };
589
- expect(opts.onUsage).toBeUndefined();
590
- });
591
- });
592
- });