specrails-hub 0.1.0

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.
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
2
+ import { EventEmitter } from 'events'
3
+ import { Readable } from 'stream'
4
+
5
+ // Mock child_process before importing chat-manager
6
+ vi.mock('child_process', () => ({
7
+ spawn: vi.fn(),
8
+ execSync: vi.fn(),
9
+ }))
10
+
11
+ vi.mock('tree-kill', () => ({
12
+ default: vi.fn(),
13
+ }))
14
+
15
+ import { spawn as mockSpawn, execSync as mockExecSync } from 'child_process'
16
+ import treeKill from 'tree-kill'
17
+ import { ChatManager } from './chat-manager'
18
+ import { initDb, createConversation, getConversation } from './db'
19
+ import type { DbInstance } from './db'
20
+
21
+ function createMockChildProcess() {
22
+ const child = new EventEmitter() as any
23
+ child.stdout = new Readable({ read() {} })
24
+ child.stderr = new Readable({ read() {} })
25
+ child.pid = 42000
26
+ child.kill = vi.fn()
27
+ return child
28
+ }
29
+
30
+ function pushLine(child: any, line: string) {
31
+ child.stdout.push(line + '\n')
32
+ }
33
+
34
+ function finishProcess(child: any, code: number): Promise<void> {
35
+ // Push EOF on stdout, then wait for readline to drain before emitting close.
36
+ // readline processes data asynchronously; setImmediate ensures all buffered
37
+ // line events have fired before the close handler runs.
38
+ return new Promise((resolve) => {
39
+ child.stdout.push(null)
40
+ setImmediate(() => {
41
+ child.emit('close', code)
42
+ resolve()
43
+ })
44
+ })
45
+ }
46
+
47
+ function assistantEvent(text: string): string {
48
+ return JSON.stringify({
49
+ type: 'assistant',
50
+ message: { content: [{ type: 'text', text }] },
51
+ })
52
+ }
53
+
54
+ function resultEvent(sessionId: string): string {
55
+ return JSON.stringify({ type: 'result', session_id: sessionId })
56
+ }
57
+
58
+ function getBroadcastedByType(broadcast: ReturnType<typeof vi.fn>, type: string) {
59
+ return broadcast.mock.calls
60
+ .map((args) => args[0] as Record<string, unknown>)
61
+ .filter((msg) => msg.type === type)
62
+ }
63
+
64
+ const TEST_CONV_ID = 'conv-test-001'
65
+
66
+ describe('ChatManager', () => {
67
+ let db: DbInstance
68
+ let broadcast: ReturnType<typeof vi.fn>
69
+ let cm: ChatManager
70
+
71
+ beforeEach(() => {
72
+ vi.resetAllMocks()
73
+ vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
74
+ db = initDb(':memory:')
75
+ broadcast = vi.fn()
76
+ cm = new ChatManager(broadcast, db)
77
+ })
78
+
79
+ afterEach(() => {
80
+ vi.restoreAllMocks()
81
+ })
82
+
83
+ function setupConversation(model = 'claude-sonnet-4-5'): string {
84
+ createConversation(db, { id: TEST_CONV_ID, model })
85
+ return TEST_CONV_ID
86
+ }
87
+
88
+ // ─── Test 1: sendMessage persists user message and triggers chat_stream + chat_done ─
89
+
90
+ it('sendMessage persists user message and triggers chat_stream + chat_done broadcasts', async () => {
91
+ const convId = setupConversation()
92
+ const child = createMockChildProcess()
93
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
94
+
95
+ const sendPromise = cm.sendMessage(convId, 'Hello world')
96
+
97
+ pushLine(child, assistantEvent('Hello '))
98
+ pushLine(child, assistantEvent('back!'))
99
+ pushLine(child, resultEvent('sess-abc'))
100
+ await finishProcess(child, 0)
101
+
102
+ await sendPromise
103
+
104
+ const streamMsgs = getBroadcastedByType(broadcast, 'chat_stream')
105
+ expect(streamMsgs.length).toBeGreaterThan(0)
106
+ expect(streamMsgs[0].conversationId).toBe(convId)
107
+ expect(streamMsgs[0].delta).toBeTruthy()
108
+
109
+ const doneMsgs = getBroadcastedByType(broadcast, 'chat_done')
110
+ expect(doneMsgs).toHaveLength(1)
111
+ expect(doneMsgs[0].conversationId).toBe(convId)
112
+ expect(doneMsgs[0].fullText).toBe('Hello back!')
113
+ })
114
+
115
+ // ─── Test 2: abort triggers chat_error { error: 'aborted' } ───────────────
116
+
117
+ it('abort triggers chat_error with aborted reason', async () => {
118
+ const convId = setupConversation()
119
+ const child = createMockChildProcess()
120
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
121
+
122
+ const sendPromise = cm.sendMessage(convId, 'Do something')
123
+
124
+ expect(cm.isActive(convId)).toBe(true)
125
+ cm.abort(convId)
126
+
127
+ await finishProcess(child, 1)
128
+ await sendPromise
129
+
130
+ const errorMsgs = getBroadcastedByType(broadcast, 'chat_error')
131
+ expect(errorMsgs.length).toBeGreaterThan(0)
132
+ expect(errorMsgs[0].conversationId).toBe(convId)
133
+ expect(errorMsgs[0].error).toBe('aborted')
134
+ expect(vi.mocked(treeKill)).toHaveBeenCalledWith(child.pid, 'SIGTERM')
135
+ })
136
+
137
+ // ─── Test 3: :::command block triggers chat_command_proposal ──────────────
138
+
139
+ it(':::command block in response triggers chat_command_proposal broadcast', async () => {
140
+ const convId = setupConversation()
141
+ const child = createMockChildProcess()
142
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
143
+
144
+ const sendPromise = cm.sendMessage(convId, 'What should I do?')
145
+
146
+ const responseWithCommand = 'You should run:\n:::command\n/sr:implement #5\n:::\nThis will help.'
147
+ pushLine(child, assistantEvent(responseWithCommand))
148
+ pushLine(child, resultEvent('sess-xyz'))
149
+ await finishProcess(child, 0)
150
+
151
+ await sendPromise
152
+
153
+ const proposalMsgs = getBroadcastedByType(broadcast, 'chat_command_proposal')
154
+ expect(proposalMsgs).toHaveLength(1)
155
+ expect(proposalMsgs[0].conversationId).toBe(convId)
156
+ expect(proposalMsgs[0].command).toBe('/sr:implement #5')
157
+ })
158
+
159
+ // ─── Test 4: duplicate :::command blocks not emitted twice ────────────────
160
+
161
+ it('duplicate :::command blocks in same response are not emitted twice', async () => {
162
+ const convId = setupConversation()
163
+ const child = createMockChildProcess()
164
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
165
+
166
+ const sendPromise = cm.sendMessage(convId, 'Suggest something')
167
+
168
+ // Emit the same command twice across two chunks (buffer accumulates)
169
+ pushLine(child, assistantEvent(':::command\n/sr:implement #1\n:::'))
170
+ pushLine(child, assistantEvent(' and again :::command\n/sr:implement #1\n:::'))
171
+ pushLine(child, resultEvent('sess-dup'))
172
+ await finishProcess(child, 0)
173
+
174
+ await sendPromise
175
+
176
+ const proposalMsgs = getBroadcastedByType(broadcast, 'chat_command_proposal')
177
+ expect(proposalMsgs).toHaveLength(1)
178
+ })
179
+
180
+ // ─── Test 5: session_id stored in DB after first turn ────────────────────
181
+
182
+ it('session_id is stored in DB after first turn completes', async () => {
183
+ const convId = setupConversation()
184
+ const child = createMockChildProcess()
185
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
186
+
187
+ const sendPromise = cm.sendMessage(convId, 'Hello')
188
+
189
+ pushLine(child, assistantEvent('Hi there'))
190
+ pushLine(child, resultEvent('sess-stored'))
191
+ await finishProcess(child, 0)
192
+
193
+ await sendPromise
194
+
195
+ const conv = getConversation(db, convId)
196
+ expect(conv?.session_id).toBe('sess-stored')
197
+ })
198
+
199
+ // ─── Test 6: isActive returns true while running, false after close ───────
200
+
201
+ it('isActive returns true while process is running and false after close', async () => {
202
+ const convId = setupConversation()
203
+ const child = createMockChildProcess()
204
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
205
+
206
+ const sendPromise = cm.sendMessage(convId, 'Are you active?')
207
+ expect(cm.isActive(convId)).toBe(true)
208
+
209
+ pushLine(child, assistantEvent('Yes'))
210
+ pushLine(child, resultEvent('sess-active'))
211
+ await finishProcess(child, 0)
212
+
213
+ await sendPromise
214
+ expect(cm.isActive(convId)).toBe(false)
215
+ })
216
+ })
@@ -0,0 +1,289 @@
1
+ import { spawn, execSync, ChildProcess } from 'child_process'
2
+ import { createInterface } from 'readline'
3
+ import treeKill from 'tree-kill'
4
+ import type { WsMessage } from './types'
5
+ import type { DbInstance } from './db'
6
+ import { getConversation, addMessage, updateConversation } from './db'
7
+
8
+ const SYSTEM_PROMPT =
9
+ 'You are a project assistant with full access to this repository via Claude Code. ' +
10
+ 'You can help answer questions about the codebase, explain SpecRails concepts, and suggest commands to run. ' +
11
+ 'When you want to suggest a SpecRails command for the user to execute, wrap it in a command block like this: ' +
12
+ ':::command\n/sr:implement #42\n::: ' +
13
+ 'The user will be prompted to confirm before the command runs.'
14
+
15
+ function claudeOnPath(): boolean {
16
+ try {
17
+ execSync('which claude', { stdio: 'ignore' })
18
+ return true
19
+ } catch {
20
+ return false
21
+ }
22
+ }
23
+
24
+ function extractTextFromEvent(event: Record<string, unknown>): string | null {
25
+ const type = event.type as string
26
+ if (type === 'assistant') {
27
+ const content = event.message as { content?: Array<{ type: string; text?: string }> } | undefined
28
+ const texts = (content?.content ?? [])
29
+ .filter((c) => c.type === 'text')
30
+ .map((c) => c.text ?? '')
31
+ return texts.join('') || null
32
+ }
33
+ return null
34
+ }
35
+
36
+ function extractCommandProposals(text: string): string[] {
37
+ const regex = /:::command\s*\n([\s\S]*?):::/g
38
+ const results: string[] = []
39
+ let match: RegExpExecArray | null
40
+ while ((match = regex.exec(text)) !== null) {
41
+ results.push(match[1].trim())
42
+ }
43
+ return results
44
+ }
45
+
46
+ // ─── ChatManager ──────────────────────────────────────────────────────────────
47
+
48
+ export class ChatManager {
49
+ private _broadcast: (msg: WsMessage) => void
50
+ private _db: DbInstance
51
+ private _activeProcesses: Map<string, ChildProcess>
52
+ private _buffers: Map<string, string>
53
+ private _emittedProposals: Map<string, Set<string>>
54
+ private _abortingConversations: Set<string>
55
+
56
+ private _cwd: string | undefined
57
+
58
+ constructor(broadcast: (msg: WsMessage) => void, db: DbInstance, cwd?: string) {
59
+ this._broadcast = broadcast
60
+ this._db = db
61
+ this._cwd = cwd
62
+ this._activeProcesses = new Map()
63
+ this._buffers = new Map()
64
+ this._emittedProposals = new Map()
65
+ this._abortingConversations = new Set()
66
+ }
67
+
68
+ isActive(conversationId: string): boolean {
69
+ return this._activeProcesses.has(conversationId)
70
+ }
71
+
72
+ async sendMessage(conversationId: string, userText: string): Promise<void> {
73
+ if (this._activeProcesses.has(conversationId)) {
74
+ console.warn(`[ChatManager] conversation ${conversationId} already has an active stream`)
75
+ return
76
+ }
77
+
78
+ if (!claudeOnPath()) {
79
+ this._broadcast({
80
+ type: 'chat_error',
81
+ conversationId,
82
+ error: 'CLAUDE_NOT_FOUND',
83
+ timestamp: new Date().toISOString(),
84
+ })
85
+ return
86
+ }
87
+
88
+ const conversation = getConversation(this._db, conversationId)
89
+ if (!conversation) {
90
+ console.warn(`[ChatManager] conversation ${conversationId} not found`)
91
+ return
92
+ }
93
+
94
+ // Check if this is turn 1 (session_id was null before this message)
95
+ const isFirstTurn = conversation.session_id === null
96
+
97
+ // Persist user message
98
+ addMessage(this._db, { conversation_id: conversationId, role: 'user', content: userText })
99
+
100
+ // Build spawn args
101
+ const args: string[] = [
102
+ '--model', conversation.model,
103
+ '--dangerously-skip-permissions',
104
+ '--output-format', 'stream-json',
105
+ '--verbose',
106
+ '--system-prompt', SYSTEM_PROMPT,
107
+ '-p', userText,
108
+ ]
109
+
110
+ if (conversation.session_id) {
111
+ args.push('--resume', conversation.session_id)
112
+ }
113
+
114
+ const child = spawn('claude', args, {
115
+ env: process.env,
116
+ shell: false,
117
+ stdio: ['ignore', 'pipe', 'pipe'],
118
+ cwd: this._cwd,
119
+ })
120
+
121
+ this._activeProcesses.set(conversationId, child)
122
+ this._buffers.set(conversationId, '')
123
+ this._emittedProposals.set(conversationId, new Set())
124
+
125
+ let capturedSessionId: string | null = null
126
+
127
+ const stdoutReader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
128
+
129
+ stdoutReader.on('line', (line) => {
130
+ let parsed: Record<string, unknown> | null = null
131
+ try { parsed = JSON.parse(line) } catch { /* skip non-JSON */ }
132
+ if (!parsed) return
133
+
134
+ const eventType = parsed.type as string
135
+
136
+ if (eventType === 'result') {
137
+ const sid = parsed.session_id as string | undefined
138
+ if (sid) capturedSessionId = sid
139
+ }
140
+
141
+ const newText = extractTextFromEvent(parsed)
142
+ if (newText) {
143
+ const prev = this._buffers.get(conversationId) ?? ''
144
+ const updated = prev + newText
145
+ this._buffers.set(conversationId, updated)
146
+
147
+ this._broadcast({
148
+ type: 'chat_stream',
149
+ conversationId,
150
+ delta: newText,
151
+ timestamp: new Date().toISOString(),
152
+ })
153
+
154
+ // Check for new command proposals
155
+ const proposals = extractCommandProposals(updated)
156
+ const emitted = this._emittedProposals.get(conversationId)
157
+ if (emitted) {
158
+ for (const proposal of proposals) {
159
+ if (!emitted.has(proposal)) {
160
+ emitted.add(proposal)
161
+ this._broadcast({
162
+ type: 'chat_command_proposal',
163
+ conversationId,
164
+ command: proposal,
165
+ timestamp: new Date().toISOString(),
166
+ })
167
+ }
168
+ }
169
+ }
170
+ }
171
+ })
172
+
173
+ return new Promise<void>((resolve) => {
174
+ child.on('close', (code) => {
175
+ const fullText = this._buffers.get(conversationId) ?? ''
176
+ const wasAborting = this._abortingConversations.has(conversationId)
177
+
178
+ // Clean up tracking state
179
+ this._activeProcesses.delete(conversationId)
180
+ this._buffers.delete(conversationId)
181
+ this._emittedProposals.delete(conversationId)
182
+ this._abortingConversations.delete(conversationId)
183
+
184
+ if (wasAborting) {
185
+ // abort already emitted chat_error
186
+ resolve()
187
+ return
188
+ }
189
+
190
+ if (code === 0) {
191
+ // Persist assistant message
192
+ if (fullText) {
193
+ addMessage(this._db, { conversation_id: conversationId, role: 'assistant', content: fullText })
194
+ }
195
+
196
+ // Update session_id
197
+ if (capturedSessionId) {
198
+ updateConversation(this._db, conversationId, { session_id: capturedSessionId })
199
+ }
200
+
201
+ this._broadcast({
202
+ type: 'chat_done',
203
+ conversationId,
204
+ fullText,
205
+ timestamp: new Date().toISOString(),
206
+ })
207
+
208
+ // Auto-title on first turn
209
+ if (isFirstTurn && fullText) {
210
+ this._autoTitle(conversationId, userText, fullText)
211
+ }
212
+ } else {
213
+ this._broadcast({
214
+ type: 'chat_error',
215
+ conversationId,
216
+ error: `Process exited with code ${code ?? 'unknown'}`,
217
+ timestamp: new Date().toISOString(),
218
+ })
219
+ }
220
+
221
+ resolve()
222
+ })
223
+ })
224
+ }
225
+
226
+ abort(conversationId: string): void {
227
+ const child = this._activeProcesses.get(conversationId)
228
+ if (!child || !child.pid) return
229
+
230
+ this._abortingConversations.add(conversationId)
231
+ treeKill(child.pid, 'SIGTERM')
232
+
233
+ this._broadcast({
234
+ type: 'chat_error',
235
+ conversationId,
236
+ error: 'aborted',
237
+ timestamp: new Date().toISOString(),
238
+ })
239
+ }
240
+
241
+ private _autoTitle(conversationId: string, firstUserMsg: string, firstResponse: string): void {
242
+ try {
243
+ const titlePrompt =
244
+ `Generate a 4-6 word title for this conversation. Output ONLY the title text, no quotes or punctuation.\n\n` +
245
+ `User: ${firstUserMsg.slice(0, 200)}\nAssistant: ${firstResponse.slice(0, 300)}`
246
+
247
+ const child = spawn('claude', [
248
+ '--dangerously-skip-permissions',
249
+ '--output-format', 'stream-json',
250
+ '--verbose',
251
+ '-p', titlePrompt,
252
+ ], {
253
+ env: process.env,
254
+ shell: false,
255
+ stdio: ['ignore', 'pipe', 'pipe'],
256
+ cwd: this._cwd,
257
+ })
258
+
259
+ let titleText = ''
260
+ const reader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
261
+
262
+ reader.on('line', (line) => {
263
+ let parsed: Record<string, unknown> | null = null
264
+ try { parsed = JSON.parse(line) } catch { return }
265
+ if (!parsed) return
266
+
267
+ // Take only the first assistant event's text
268
+ if (!titleText) {
269
+ const text = extractTextFromEvent(parsed)
270
+ if (text) titleText = text.trim()
271
+ }
272
+ })
273
+
274
+ child.on('close', (code) => {
275
+ if (code === 0 && titleText) {
276
+ updateConversation(this._db, conversationId, { title: titleText })
277
+ this._broadcast({
278
+ type: 'chat_title_update',
279
+ conversationId,
280
+ title: titleText,
281
+ timestamp: new Date().toISOString(),
282
+ })
283
+ }
284
+ })
285
+ } catch {
286
+ // auto-title is fire-and-forget; failure is silent
287
+ }
288
+ }
289
+ }