@shaykec/bridge 0.4.18 → 0.4.19

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaykec/bridge",
3
- "version": "0.4.18",
3
+ "version": "0.4.19",
4
4
  "type": "module",
5
5
  "description": "Communication hub — HTTP + WebSocket + SSE server with template engine",
6
6
  "main": "src/server.js",
@@ -20,6 +20,7 @@ import {
20
20
  MSG_CHAT_STATUS,
21
21
  } from '@shaykec/shared';
22
22
 
23
+ import { randomUUID } from 'crypto';
23
24
  import { execSync } from 'child_process';
24
25
  import { createRequire } from 'module';
25
26
  import { dirname, join } from 'path';
@@ -85,9 +86,18 @@ async function getSDK() {
85
86
  * Manages multiple Claude Code SDK sessions.
86
87
  */
87
88
  export class ClaudeSessionManager {
88
- constructor() {
89
+ /**
90
+ * @param {object} [sdkOverride] - Injected SDK for testing
91
+ */
92
+ constructor(sdkOverride) {
89
93
  /** @type {Map<string, SessionEntry>} */
90
94
  this._sessions = new Map();
95
+ this._sdkOverride = sdkOverride || null;
96
+ }
97
+
98
+ /** @private */
99
+ async _getSDK() {
100
+ return this._sdkOverride || getSDK();
91
101
  }
92
102
 
93
103
  /**
@@ -96,7 +106,7 @@ export class ClaudeSessionManager {
96
106
  */
97
107
  async isAvailable() {
98
108
  try {
99
- await getSDK();
109
+ await this._getSDK();
100
110
  return true;
101
111
  } catch {
102
112
  return false;
@@ -141,21 +151,24 @@ export class ClaudeSessionManager {
141
151
 
142
152
  /**
143
153
  * Create a new chat session.
154
+ *
155
+ * The SDK's sessionId isn't available until after the first message
156
+ * exchange, so we generate our own UUID to return immediately and
157
+ * capture the real SDK sessionId lazily during sendMessage().
158
+ *
144
159
  * @param {ChatSessionOptions} options
145
- * @returns {Promise<string>} sessionId
160
+ * @returns {Promise<string>} sessionId (our UUID handle)
146
161
  */
147
162
  async createSession(options = {}) {
148
- const sdk = await getSDK();
163
+ const sdk = await this._getSDK();
149
164
  const onMessage = options.onMessage;
150
165
 
151
166
  const sessionOpts = {
167
+ model: options.model || 'claude-sonnet-4-6',
152
168
  allowedTools: [
153
169
  'Bash(*)', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
154
170
  ],
155
171
  permissionMode: 'bypassPermissions',
156
- allowDangerouslySkipPermissions: true,
157
- settingSources: ['user', 'project'],
158
- includePartialMessages: true,
159
172
  };
160
173
 
161
174
  if (options.cwd) sessionOpts.cwd = options.cwd;
@@ -166,12 +179,15 @@ export class ClaudeSessionManager {
166
179
 
167
180
  try {
168
181
  const session = sdk.unstable_v2_createSession(sessionOpts);
169
- const sessionId = session.sessionId;
182
+ // session.sessionId throws until after first send()+stream()
183
+ // so we use our own UUID as the map key
184
+ const sessionId = randomUUID();
170
185
 
171
186
  this._sessions.set(sessionId, {
172
187
  session,
173
188
  streaming: false,
174
189
  onMessage,
190
+ sdkSessionId: null, // captured after first message
175
191
  });
176
192
 
177
193
  this._emit(sessionId, MSG_CHAT_STATUS, { status: 'started', sessionId });
@@ -193,7 +209,7 @@ export class ClaudeSessionManager {
193
209
  * @returns {Promise<string>} sessionId
194
210
  */
195
211
  async resumeSession(sessionId, options = {}) {
196
- const sdk = await getSDK();
212
+ const sdk = await this._getSDK();
197
213
 
198
214
  // Close existing entry for this ID if present
199
215
  await this.closeSession(sessionId);
@@ -201,13 +217,11 @@ export class ClaudeSessionManager {
201
217
  const onMessage = options.onMessage;
202
218
 
203
219
  const sessionOpts = {
220
+ model: options.model || 'claude-sonnet-4-6',
204
221
  allowedTools: [
205
222
  'Bash(*)', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
206
223
  ],
207
224
  permissionMode: 'bypassPermissions',
208
- allowDangerouslySkipPermissions: true,
209
- settingSources: ['user', 'project'],
210
- includePartialMessages: true,
211
225
  };
212
226
 
213
227
  if (options.cwd) sessionOpts.cwd = options.cwd;
@@ -217,12 +231,14 @@ export class ClaudeSessionManager {
217
231
  }
218
232
 
219
233
  try {
234
+ // For resumed sessions, sessionId is available immediately
220
235
  const session = sdk.unstable_v2_resumeSession(sessionId, sessionOpts);
221
236
 
222
237
  this._sessions.set(sessionId, {
223
238
  session,
224
239
  streaming: false,
225
240
  onMessage,
241
+ sdkSessionId: sessionId,
226
242
  });
227
243
 
228
244
  this._emit(sessionId, MSG_CHAT_STATUS, { status: 'resumed', sessionId });
@@ -262,6 +278,13 @@ export class ClaudeSessionManager {
262
278
  let currentText = '';
263
279
 
264
280
  for await (const msg of entry.session.stream()) {
281
+ // Capture the real SDK sessionId once it becomes available
282
+ if (!entry.sdkSessionId) {
283
+ try {
284
+ entry.sdkSessionId = entry.session.sessionId;
285
+ } catch { /* not ready yet */ }
286
+ }
287
+
265
288
  if (msg.type === 'assistant') {
266
289
  const textBlocks = (msg.message?.content || []).filter(b => b.type === 'text');
267
290
  for (const block of textBlocks) {
@@ -305,6 +328,16 @@ export class ClaudeSessionManager {
305
328
  }
306
329
  }
307
330
 
331
+ /**
332
+ * Get the real SDK session ID for a session (available after first message).
333
+ * @param {string} sessionId - Our UUID handle
334
+ * @returns {string|null}
335
+ */
336
+ getSdkSessionId(sessionId) {
337
+ const entry = this._sessions.get(sessionId);
338
+ return entry?.sdkSessionId || null;
339
+ }
340
+
308
341
  /**
309
342
  * Stop the current generation for a specific session.
310
343
  * @param {string} sessionId
@@ -327,7 +360,7 @@ export class ClaudeSessionManager {
327
360
  */
328
361
  async listSessions(dir) {
329
362
  try {
330
- const sdk = await getSDK();
363
+ const sdk = await this._getSDK();
331
364
  const sessions = await sdk.listSessions({ dir, limit: 20 });
332
365
  return sessions.map(s => ({
333
366
  sessionId: s.sessionId,
@@ -1,32 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { ClaudeSessionManager } from './claude-session.js';
3
3
 
4
- // Mock the SDK
5
- vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
6
- unstable_v2_createSession: vi.fn(),
7
- unstable_v2_resumeSession: vi.fn(),
8
- listSessions: vi.fn(),
9
- }));
10
-
11
- import {
12
- unstable_v2_createSession,
13
- unstable_v2_resumeSession,
14
- listSessions,
15
- } from '@anthropic-ai/claude-agent-sdk';
16
-
17
4
  describe('ClaudeSessionManager', () => {
18
5
  let manager;
19
6
  let emittedMessages;
20
-
21
- beforeEach(() => {
22
- vi.clearAllMocks();
23
- manager = new ClaudeSessionManager();
24
- emittedMessages = [];
25
- });
26
-
27
- const mockOnMessage = (envelope) => {
28
- emittedMessages.push(envelope);
29
- };
7
+ let mockSDK;
30
8
 
31
9
  function makeMockSession(id) {
32
10
  return {
@@ -37,23 +15,40 @@ describe('ClaudeSessionManager', () => {
37
15
  };
38
16
  }
39
17
 
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ emittedMessages = [];
21
+ mockSDK = {
22
+ unstable_v2_createSession: vi.fn(),
23
+ unstable_v2_resumeSession: vi.fn(),
24
+ listSessions: vi.fn(),
25
+ };
26
+ manager = new ClaudeSessionManager(mockSDK);
27
+ });
28
+
29
+ const mockOnMessage = (envelope) => {
30
+ emittedMessages.push(envelope);
31
+ };
32
+
40
33
  describe('createSession', () => {
41
34
  it('creates a session and emits started status', async () => {
42
- const mockSession = makeMockSession('test-session-123');
43
- unstable_v2_createSession.mockReturnValue(mockSession);
35
+ const mockSession = makeMockSession('sdk-id-1');
36
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
44
37
 
45
38
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
46
39
 
47
- expect(sessionId).toBe('test-session-123');
48
- expect(manager.isActive('test-session-123')).toBe(true);
40
+ // sessionId is a UUID we generate, not the SDK's
41
+ expect(sessionId).toMatch(/^[0-9a-f-]{36}$/);
42
+ expect(manager.isActive(sessionId)).toBe(true);
49
43
  expect(emittedMessages).toHaveLength(1);
50
44
  expect(emittedMessages[0].type).toBe('chat:status');
51
45
  expect(emittedMessages[0].payload.status).toBe('started');
46
+ expect(emittedMessages[0].payload.sessionId).toBe(sessionId);
52
47
  });
53
48
 
54
- it('passes cwd and pluginDir to the SDK', async () => {
49
+ it('passes model, cwd and pluginDir to the SDK', async () => {
55
50
  const mockSession = makeMockSession('s1');
56
- unstable_v2_createSession.mockReturnValue(mockSession);
51
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
57
52
 
58
53
  await manager.createSession({
59
54
  cwd: '/test/dir',
@@ -61,13 +56,14 @@ describe('ClaudeSessionManager', () => {
61
56
  onMessage: mockOnMessage,
62
57
  });
63
58
 
64
- const opts = unstable_v2_createSession.mock.calls[0][0];
59
+ const opts = mockSDK.unstable_v2_createSession.mock.calls[0][0];
60
+ expect(opts.model).toBe('claude-sonnet-4-6');
65
61
  expect(opts.cwd).toBe('/test/dir');
66
62
  expect(opts.plugins).toEqual([{ type: 'local', path: '/test/plugin' }]);
67
63
  });
68
64
 
69
65
  it('emits error status on SDK failure', async () => {
70
- unstable_v2_createSession.mockImplementation(() => {
66
+ mockSDK.unstable_v2_createSession.mockImplementation(() => {
71
67
  throw new Error('SDK init failed');
72
68
  });
73
69
 
@@ -83,12 +79,12 @@ describe('ClaudeSessionManager', () => {
83
79
  describe('resumeSession', () => {
84
80
  it('resumes a session by ID', async () => {
85
81
  const mockSession = makeMockSession('existing-session');
86
- unstable_v2_resumeSession.mockReturnValue(mockSession);
82
+ mockSDK.unstable_v2_resumeSession.mockReturnValue(mockSession);
87
83
 
88
84
  const sessionId = await manager.resumeSession('existing-session', { onMessage: mockOnMessage });
89
85
 
90
86
  expect(sessionId).toBe('existing-session');
91
- expect(unstable_v2_resumeSession).toHaveBeenCalledWith('existing-session', expect.any(Object));
87
+ expect(mockSDK.unstable_v2_resumeSession).toHaveBeenCalledWith('existing-session', expect.any(Object));
92
88
  expect(emittedMessages[0].payload.status).toBe('resumed');
93
89
  });
94
90
  });
@@ -106,7 +102,7 @@ describe('ClaudeSessionManager', () => {
106
102
 
107
103
  const mockSession = makeMockSession('stream-test');
108
104
  mockSession.stream.mockReturnValue(fakeStream());
109
- unstable_v2_createSession.mockReturnValue(mockSession);
105
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
110
106
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
111
107
 
112
108
  emittedMessages = [];
@@ -121,6 +117,25 @@ describe('ClaudeSessionManager', () => {
121
117
  expect(types).toContain('chat:assistant');
122
118
  });
123
119
 
120
+ it('captures SDK sessionId during streaming', async () => {
121
+ async function* fakeStream() {
122
+ yield { type: 'system' };
123
+ yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Hi' }] } };
124
+ yield { type: 'result' };
125
+ }
126
+
127
+ const mockSession = makeMockSession('real-sdk-id');
128
+ mockSession.stream.mockReturnValue(fakeStream());
129
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
130
+ const sessionId = await manager.createSession({ onMessage: mockOnMessage });
131
+
132
+ expect(manager.getSdkSessionId(sessionId)).toBeNull();
133
+
134
+ await manager.sendMessage(sessionId, 'test');
135
+
136
+ expect(manager.getSdkSessionId(sessionId)).toBe('real-sdk-id');
137
+ });
138
+
124
139
  it('emits tool-use and tool-result events', async () => {
125
140
  async function* fakeStream() {
126
141
  yield { type: 'tool_use', name: 'Bash', id: 'tool-1', input: { command: 'ls' } };
@@ -130,7 +145,7 @@ describe('ClaudeSessionManager', () => {
130
145
 
131
146
  const mockSession = makeMockSession('tool-test');
132
147
  mockSession.stream.mockReturnValue(fakeStream());
133
- unstable_v2_createSession.mockReturnValue(mockSession);
148
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
134
149
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
135
150
 
136
151
  emittedMessages = [];
@@ -148,9 +163,9 @@ describe('ClaudeSessionManager', () => {
148
163
 
149
164
  describe('multiple sessions', () => {
150
165
  it('supports multiple concurrent sessions', async () => {
151
- const session1 = makeMockSession('session-1');
152
- const session2 = makeMockSession('session-2');
153
- unstable_v2_createSession
166
+ const session1 = makeMockSession('sdk-1');
167
+ const session2 = makeMockSession('sdk-2');
168
+ mockSDK.unstable_v2_createSession
154
169
  .mockReturnValueOnce(session1)
155
170
  .mockReturnValueOnce(session2);
156
171
 
@@ -160,37 +175,36 @@ describe('ClaudeSessionManager', () => {
160
175
  const id1 = await manager.createSession({ onMessage: (e) => messages1.push(e) });
161
176
  const id2 = await manager.createSession({ onMessage: (e) => messages2.push(e) });
162
177
 
163
- expect(id1).toBe('session-1');
164
- expect(id2).toBe('session-2');
165
- expect(manager.isActive('session-1')).toBe(true);
166
- expect(manager.isActive('session-2')).toBe(true);
178
+ expect(id1).not.toBe(id2);
179
+ expect(manager.isActive(id1)).toBe(true);
180
+ expect(manager.isActive(id2)).toBe(true);
167
181
  expect(manager.getActiveSessionIds()).toHaveLength(2);
168
182
 
169
183
  // Status messages go to the right callback
170
- expect(messages1[0].payload.sessionId).toBe('session-1');
171
- expect(messages2[0].payload.sessionId).toBe('session-2');
184
+ expect(messages1[0].payload.sessionId).toBe(id1);
185
+ expect(messages2[0].payload.sessionId).toBe(id2);
172
186
  });
173
187
 
174
188
  it('closing one session does not affect others', async () => {
175
- const session1 = makeMockSession('session-1');
176
- const session2 = makeMockSession('session-2');
177
- unstable_v2_createSession
189
+ const session1 = makeMockSession('sdk-1');
190
+ const session2 = makeMockSession('sdk-2');
191
+ mockSDK.unstable_v2_createSession
178
192
  .mockReturnValueOnce(session1)
179
193
  .mockReturnValueOnce(session2);
180
194
 
181
- await manager.createSession({ onMessage: mockOnMessage });
182
- await manager.createSession({ onMessage: mockOnMessage });
195
+ const id1 = await manager.createSession({ onMessage: mockOnMessage });
196
+ const id2 = await manager.createSession({ onMessage: mockOnMessage });
183
197
 
184
- await manager.closeSession('session-1');
198
+ await manager.closeSession(id1);
185
199
 
186
- expect(manager.isActive('session-1')).toBe(false);
187
- expect(manager.isActive('session-2')).toBe(true);
200
+ expect(manager.isActive(id1)).toBe(false);
201
+ expect(manager.isActive(id2)).toBe(true);
188
202
  });
189
203
 
190
204
  it('closeAll closes all sessions', async () => {
191
- const session1 = makeMockSession('session-1');
192
- const session2 = makeMockSession('session-2');
193
- unstable_v2_createSession
205
+ const session1 = makeMockSession('sdk-1');
206
+ const session2 = makeMockSession('sdk-2');
207
+ mockSDK.unstable_v2_createSession
194
208
  .mockReturnValueOnce(session1)
195
209
  .mockReturnValueOnce(session2);
196
210
 
@@ -208,7 +222,7 @@ describe('ClaudeSessionManager', () => {
208
222
  describe('stop', () => {
209
223
  it('is a no-op when not streaming', async () => {
210
224
  const mockSession = makeMockSession('s1');
211
- unstable_v2_createSession.mockReturnValue(mockSession);
225
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
212
226
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
213
227
 
214
228
  emittedMessages = [];
@@ -222,7 +236,7 @@ describe('ClaudeSessionManager', () => {
222
236
 
223
237
  describe('listSessions', () => {
224
238
  it('returns mapped session list', async () => {
225
- listSessions.mockResolvedValue([
239
+ mockSDK.listSessions.mockResolvedValue([
226
240
  { sessionId: 's1', summary: 'Test', lastModified: 12345 },
227
241
  { sessionId: 's2', summary: 'Other', lastModified: 67890 },
228
242
  ]);
@@ -233,7 +247,7 @@ describe('ClaudeSessionManager', () => {
233
247
  });
234
248
 
235
249
  it('returns empty array on error', async () => {
236
- listSessions.mockRejectedValue(new Error('fail'));
250
+ mockSDK.listSessions.mockRejectedValue(new Error('fail'));
237
251
  const sessions = await manager.listSessions('/test');
238
252
  expect(sessions).toEqual([]);
239
253
  });
@@ -242,7 +256,7 @@ describe('ClaudeSessionManager', () => {
242
256
  describe('closeSession', () => {
243
257
  it('closes active session and removes from map', async () => {
244
258
  const mockSession = makeMockSession('s1');
245
- unstable_v2_createSession.mockReturnValue(mockSession);
259
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
246
260
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
247
261
 
248
262
  expect(manager.isActive(sessionId)).toBe(true);
@@ -252,7 +266,7 @@ describe('ClaudeSessionManager', () => {
252
266
  });
253
267
 
254
268
  describe('isAvailable', () => {
255
- it('returns true when SDK is loadable', async () => {
269
+ it('returns true when SDK is available', async () => {
256
270
  const available = await manager.isAvailable();
257
271
  expect(available).toBe(true);
258
272
  });
@@ -268,7 +282,7 @@ describe('ClaudeSessionManager', () => {
268
282
 
269
283
  const mockSession = makeMockSession('busy');
270
284
  mockSession.stream.mockReturnValue(slowStream());
271
- unstable_v2_createSession.mockReturnValue(mockSession);
285
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
272
286
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
273
287
 
274
288
  // Start streaming (don't await — it won't finish)
@@ -290,7 +304,7 @@ describe('ClaudeSessionManager', () => {
290
304
 
291
305
  const mockSession = makeMockSession('fail-stream');
292
306
  mockSession.stream.mockReturnValue(failStream());
293
- unstable_v2_createSession.mockReturnValue(mockSession);
307
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
294
308
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
295
309
 
296
310
  emittedMessages = [];
@@ -787,10 +787,15 @@ describe('Bridge E2E: Chat message routing', () => {
787
787
  expect(data.ok).toBe(true);
788
788
  });
789
789
 
790
- it('POST /api/chat/start fails without SDK but returns structured error', async () => {
790
+ it('POST /api/chat/start returns 200 with sessionId (SDK available) or 500 with error', async () => {
791
791
  const { status, data } = await postJson('/api/chat/start', { clientId: 'test-client-123' });
792
- expect(status).toBe(500);
793
- expect(data.error).toBeDefined();
792
+ expect([200, 500]).toContain(status);
793
+ if (status === 200) {
794
+ expect(data.sessionId).toBeDefined();
795
+ expect(data.ok).toBe(true);
796
+ } else {
797
+ expect(data.error).toBeDefined();
798
+ }
794
799
  });
795
800
 
796
801
  it('POST /api/chat/message requires sessionId', async () => {