@open-mercato/ai-assistant 0.4.2-canary-c02407ff85
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/AGENTS.md +1090 -0
- package/README.md +607 -0
- package/build.mjs +92 -0
- package/dist/di.js +8 -0
- package/dist/di.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandFooter.js +80 -0
- package/dist/frontend/components/CommandPalette/CommandFooter.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandHeader.js +53 -0
- package/dist/frontend/components/CommandPalette/CommandHeader.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandInput.js +29 -0
- package/dist/frontend/components/CommandPalette/CommandInput.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandItem.js +92 -0
- package/dist/frontend/components/CommandPalette/CommandItem.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandPalette.js +244 -0
- package/dist/frontend/components/CommandPalette/CommandPalette.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandPaletteProvider.js +42 -0
- package/dist/frontend/components/CommandPalette/CommandPaletteProvider.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandPaletteWrapper.js +18 -0
- package/dist/frontend/components/CommandPalette/CommandPaletteWrapper.js.map +7 -0
- package/dist/frontend/components/CommandPalette/DebugPanel.js +215 -0
- package/dist/frontend/components/CommandPalette/DebugPanel.js.map +7 -0
- package/dist/frontend/components/CommandPalette/MessageBubble.js +64 -0
- package/dist/frontend/components/CommandPalette/MessageBubble.js.map +7 -0
- package/dist/frontend/components/CommandPalette/ToolCallConfirmation.js +91 -0
- package/dist/frontend/components/CommandPalette/ToolCallConfirmation.js.map +7 -0
- package/dist/frontend/components/CommandPalette/ToolCallDisplay.js +47 -0
- package/dist/frontend/components/CommandPalette/ToolCallDisplay.js.map +7 -0
- package/dist/frontend/components/CommandPalette/ToolChatPage.js +74 -0
- package/dist/frontend/components/CommandPalette/ToolChatPage.js.map +7 -0
- package/dist/frontend/components/CommandPalette/index.js +28 -0
- package/dist/frontend/components/CommandPalette/index.js.map +7 -0
- package/dist/frontend/constants.js +41 -0
- package/dist/frontend/constants.js.map +7 -0
- package/dist/frontend/hooks/index.js +13 -0
- package/dist/frontend/hooks/index.js.map +7 -0
- package/dist/frontend/hooks/useCommandPalette.js +1094 -0
- package/dist/frontend/hooks/useCommandPalette.js.map +7 -0
- package/dist/frontend/hooks/useMcpTools.js +66 -0
- package/dist/frontend/hooks/useMcpTools.js.map +7 -0
- package/dist/frontend/hooks/usePageContext.js +48 -0
- package/dist/frontend/hooks/usePageContext.js.map +7 -0
- package/dist/frontend/hooks/useRecentActions.js +56 -0
- package/dist/frontend/hooks/useRecentActions.js.map +7 -0
- package/dist/frontend/hooks/useRecentTools.js +55 -0
- package/dist/frontend/hooks/useRecentTools.js.map +7 -0
- package/dist/frontend/index.js +35 -0
- package/dist/frontend/index.js.map +7 -0
- package/dist/frontend/types.js +1 -0
- package/dist/frontend/types.js.map +7 -0
- package/dist/frontend/utils/index.js +7 -0
- package/dist/frontend/utils/index.js.map +7 -0
- package/dist/frontend/utils/toolMatcher.js +95 -0
- package/dist/frontend/utils/toolMatcher.js.map +7 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/ai_assistant/acl.js +14 -0
- package/dist/modules/ai_assistant/acl.js.map +7 -0
- package/dist/modules/ai_assistant/api/chat/route.js +152 -0
- package/dist/modules/ai_assistant/api/chat/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/health/route.js +27 -0
- package/dist/modules/ai_assistant/api/health/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/route/route.js +123 -0
- package/dist/modules/ai_assistant/api/route/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/settings/route.js +60 -0
- package/dist/modules/ai_assistant/api/settings/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/tools/execute/route.js +58 -0
- package/dist/modules/ai_assistant/api/tools/execute/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/tools/route.js +48 -0
- package/dist/modules/ai_assistant/api/tools/route.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js +28 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/cli.js +192 -0
- package/dist/modules/ai_assistant/cli.js.map +7 -0
- package/dist/modules/ai_assistant/di.js +11 -0
- package/dist/modules/ai_assistant/di.js.map +7 -0
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +257 -0
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/index.js +13 -0
- package/dist/modules/ai_assistant/index.js.map +7 -0
- package/dist/modules/ai_assistant/lib/ai-sdk.js +13 -0
- package/dist/modules/ai_assistant/lib/ai-sdk.js.map +7 -0
- package/dist/modules/ai_assistant/lib/api-discovery-tools.js +249 -0
- package/dist/modules/ai_assistant/lib/api-discovery-tools.js.map +7 -0
- package/dist/modules/ai_assistant/lib/api-endpoint-index-config.js +177 -0
- package/dist/modules/ai_assistant/lib/api-endpoint-index-config.js.map +7 -0
- package/dist/modules/ai_assistant/lib/api-endpoint-index.js +210 -0
- package/dist/modules/ai_assistant/lib/api-endpoint-index.js.map +7 -0
- package/dist/modules/ai_assistant/lib/auth.js +87 -0
- package/dist/modules/ai_assistant/lib/auth.js.map +7 -0
- package/dist/modules/ai_assistant/lib/chat-config.js +117 -0
- package/dist/modules/ai_assistant/lib/chat-config.js.map +7 -0
- package/dist/modules/ai_assistant/lib/client-factory.js +60 -0
- package/dist/modules/ai_assistant/lib/client-factory.js.map +7 -0
- package/dist/modules/ai_assistant/lib/http-server.js +367 -0
- package/dist/modules/ai_assistant/lib/http-server.js.map +7 -0
- package/dist/modules/ai_assistant/lib/in-process-client.js +126 -0
- package/dist/modules/ai_assistant/lib/in-process-client.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-client.js +146 -0
- package/dist/modules/ai_assistant/lib/mcp-client.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js +283 -0
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-server-config.js +160 -0
- package/dist/modules/ai_assistant/lib/mcp-server-config.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-server.js +156 -0
- package/dist/modules/ai_assistant/lib/mcp-server.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-tool-adapter.js +44 -0
- package/dist/modules/ai_assistant/lib/mcp-tool-adapter.js.map +7 -0
- package/dist/modules/ai_assistant/lib/opencode-client.js +247 -0
- package/dist/modules/ai_assistant/lib/opencode-client.js.map +7 -0
- package/dist/modules/ai_assistant/lib/opencode-handlers.js +398 -0
- package/dist/modules/ai_assistant/lib/opencode-handlers.js.map +7 -0
- package/dist/modules/ai_assistant/lib/schema-utils.js +94 -0
- package/dist/modules/ai_assistant/lib/schema-utils.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-executor.js +55 -0
- package/dist/modules/ai_assistant/lib/tool-executor.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-index-config.js +125 -0
- package/dist/modules/ai_assistant/lib/tool-index-config.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-loader.js +88 -0
- package/dist/modules/ai_assistant/lib/tool-loader.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-registry.js +65 -0
- package/dist/modules/ai_assistant/lib/tool-registry.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-search.js +192 -0
- package/dist/modules/ai_assistant/lib/tool-search.js.map +7 -0
- package/dist/modules/ai_assistant/lib/types.js +1 -0
- package/dist/modules/ai_assistant/lib/types.js.map +7 -0
- package/package.json +108 -0
- package/src/di.ts +11 -0
- package/src/frontend/components/CommandPalette/CommandFooter.tsx +113 -0
- package/src/frontend/components/CommandPalette/CommandHeader.tsx +76 -0
- package/src/frontend/components/CommandPalette/CommandInput.tsx +50 -0
- package/src/frontend/components/CommandPalette/CommandItem.tsx +111 -0
- package/src/frontend/components/CommandPalette/CommandPalette.tsx +276 -0
- package/src/frontend/components/CommandPalette/CommandPaletteProvider.tsx +60 -0
- package/src/frontend/components/CommandPalette/CommandPaletteWrapper.tsx +21 -0
- package/src/frontend/components/CommandPalette/DebugPanel.tsx +257 -0
- package/src/frontend/components/CommandPalette/MessageBubble.tsx +73 -0
- package/src/frontend/components/CommandPalette/ToolCallConfirmation.tsx +130 -0
- package/src/frontend/components/CommandPalette/ToolCallDisplay.tsx +57 -0
- package/src/frontend/components/CommandPalette/ToolChatPage.tsx +125 -0
- package/src/frontend/components/CommandPalette/index.ts +14 -0
- package/src/frontend/constants.ts +35 -0
- package/src/frontend/hooks/index.ts +5 -0
- package/src/frontend/hooks/useCommandPalette.ts +1389 -0
- package/src/frontend/hooks/useMcpTools.ts +73 -0
- package/src/frontend/hooks/usePageContext.ts +61 -0
- package/src/frontend/hooks/useRecentActions.ts +64 -0
- package/src/frontend/hooks/useRecentTools.ts +69 -0
- package/src/frontend/index.ts +39 -0
- package/src/frontend/types.ts +260 -0
- package/src/frontend/utils/index.ts +1 -0
- package/src/frontend/utils/toolMatcher.ts +127 -0
- package/src/index.ts +92 -0
- package/src/modules/ai_assistant/acl.ts +10 -0
- package/src/modules/ai_assistant/api/chat/route.ts +213 -0
- package/src/modules/ai_assistant/api/health/route.ts +30 -0
- package/src/modules/ai_assistant/api/route/route.ts +149 -0
- package/src/modules/ai_assistant/api/settings/route.ts +73 -0
- package/src/modules/ai_assistant/api/tools/execute/route.ts +71 -0
- package/src/modules/ai_assistant/api/tools/route.ts +57 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/page.meta.ts +26 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/page.tsx +12 -0
- package/src/modules/ai_assistant/cli.ts +233 -0
- package/src/modules/ai_assistant/di.ts +9 -0
- package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +418 -0
- package/src/modules/ai_assistant/index.ts +11 -0
- package/src/modules/ai_assistant/lib/ai-sdk.ts +5 -0
- package/src/modules/ai_assistant/lib/api-discovery-tools.ts +334 -0
- package/src/modules/ai_assistant/lib/api-endpoint-index-config.ts +243 -0
- package/src/modules/ai_assistant/lib/api-endpoint-index.ts +381 -0
- package/src/modules/ai_assistant/lib/auth.ts +185 -0
- package/src/modules/ai_assistant/lib/chat-config.ts +152 -0
- package/src/modules/ai_assistant/lib/client-factory.ts +130 -0
- package/src/modules/ai_assistant/lib/http-server.ts +498 -0
- package/src/modules/ai_assistant/lib/in-process-client.ts +205 -0
- package/src/modules/ai_assistant/lib/mcp-client.ts +221 -0
- package/src/modules/ai_assistant/lib/mcp-dev-server.ts +373 -0
- package/src/modules/ai_assistant/lib/mcp-server-config.ts +287 -0
- package/src/modules/ai_assistant/lib/mcp-server.ts +214 -0
- package/src/modules/ai_assistant/lib/mcp-tool-adapter.ts +76 -0
- package/src/modules/ai_assistant/lib/opencode-client.ts +426 -0
- package/src/modules/ai_assistant/lib/opencode-handlers.ts +676 -0
- package/src/modules/ai_assistant/lib/schema-utils.ts +142 -0
- package/src/modules/ai_assistant/lib/tool-executor.ts +71 -0
- package/src/modules/ai_assistant/lib/tool-index-config.ts +178 -0
- package/src/modules/ai_assistant/lib/tool-loader.ts +149 -0
- package/src/modules/ai_assistant/lib/tool-registry.ts +114 -0
- package/src/modules/ai_assistant/lib/tool-search.ts +308 -0
- package/src/modules/ai_assistant/lib/types.ts +147 -0
- package/test-schema.ts +37 -0
- package/tsconfig.json +10 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode API Route Handlers
|
|
3
|
+
*
|
|
4
|
+
* These handlers can be used by Next.js API routes to interact with OpenCode.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createOpenCodeClient,
|
|
9
|
+
type OpenCodeClient,
|
|
10
|
+
type OpenCodeQuestion,
|
|
11
|
+
} from './opencode-client'
|
|
12
|
+
|
|
13
|
+
let clientInstance: OpenCodeClient | null = null
|
|
14
|
+
|
|
15
|
+
function getClient(): OpenCodeClient {
|
|
16
|
+
if (!clientInstance) {
|
|
17
|
+
clientInstance = createOpenCodeClient()
|
|
18
|
+
}
|
|
19
|
+
return clientInstance
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type OpenCodeTestRequest = {
|
|
23
|
+
message: string
|
|
24
|
+
sessionId?: string
|
|
25
|
+
model?: {
|
|
26
|
+
providerID: string
|
|
27
|
+
modelID: string
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type OpenCodeTestResponse = {
|
|
32
|
+
sessionId: string
|
|
33
|
+
result: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type OpenCodeHealthResponse = {
|
|
37
|
+
status: 'ok' | 'error'
|
|
38
|
+
opencode?: {
|
|
39
|
+
healthy: boolean
|
|
40
|
+
version: string
|
|
41
|
+
}
|
|
42
|
+
mcp?: Record<string, { status: string; error?: string }>
|
|
43
|
+
search?: {
|
|
44
|
+
available: boolean
|
|
45
|
+
driver: string | null // 'meilisearch' or null
|
|
46
|
+
}
|
|
47
|
+
url: string
|
|
48
|
+
message?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Handle POST request to send a message to OpenCode.
|
|
53
|
+
*/
|
|
54
|
+
export async function handleOpenCodeMessage(
|
|
55
|
+
request: OpenCodeTestRequest
|
|
56
|
+
): Promise<OpenCodeTestResponse> {
|
|
57
|
+
const client = getClient()
|
|
58
|
+
|
|
59
|
+
const { message, sessionId, model } = request
|
|
60
|
+
|
|
61
|
+
if (!message) {
|
|
62
|
+
throw new Error('Message is required')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Create or get session
|
|
66
|
+
let session
|
|
67
|
+
if (sessionId) {
|
|
68
|
+
session = await client.getSession(sessionId)
|
|
69
|
+
} else {
|
|
70
|
+
session = await client.createSession()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Send message
|
|
74
|
+
const result = await client.sendMessage(session.id, message, { model })
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
sessionId: session.id,
|
|
78
|
+
result,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Handle GET request to check OpenCode health.
|
|
84
|
+
*/
|
|
85
|
+
export async function handleOpenCodeHealth(): Promise<OpenCodeHealthResponse> {
|
|
86
|
+
const client = getClient()
|
|
87
|
+
const url = process.env.OPENCODE_URL ?? 'http://localhost:4096'
|
|
88
|
+
|
|
89
|
+
// Check search service availability
|
|
90
|
+
let searchStatus: { available: boolean; driver: string | null } = {
|
|
91
|
+
available: false,
|
|
92
|
+
driver: null,
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
|
|
96
|
+
const container = await createRequestContainer()
|
|
97
|
+
const searchService = container.resolve<{
|
|
98
|
+
isStrategyAvailable: (strategy: string) => boolean
|
|
99
|
+
}>('searchService')
|
|
100
|
+
const available = searchService.isStrategyAvailable('fulltext')
|
|
101
|
+
searchStatus = {
|
|
102
|
+
available,
|
|
103
|
+
driver: available ? 'meilisearch' : null,
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Search service not available
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const [health, mcp] = await Promise.all([client.health(), client.mcpStatus()])
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
status: 'ok',
|
|
114
|
+
opencode: health,
|
|
115
|
+
mcp,
|
|
116
|
+
search: searchStatus,
|
|
117
|
+
url,
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return {
|
|
121
|
+
status: 'error',
|
|
122
|
+
search: searchStatus,
|
|
123
|
+
message: error instanceof Error ? error.message : 'OpenCode not reachable',
|
|
124
|
+
url,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Extract text content from OpenCode message response.
|
|
131
|
+
*/
|
|
132
|
+
export function extractTextFromResponse(result: unknown): string | null {
|
|
133
|
+
if (!result || typeof result !== 'object') return null
|
|
134
|
+
|
|
135
|
+
const message = result as { parts?: Array<{ type: string; text?: string }> }
|
|
136
|
+
if (!message.parts) return null
|
|
137
|
+
|
|
138
|
+
const textParts = message.parts.filter((p) => p.type === 'text' && p.text)
|
|
139
|
+
return textParts.map((p) => p.text).join('\n') || null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Response part from OpenCode - can be text, tool-call, tool-result, etc.
|
|
144
|
+
*/
|
|
145
|
+
export interface OpenCodeResponsePart {
|
|
146
|
+
id: string
|
|
147
|
+
type: string
|
|
148
|
+
text?: string
|
|
149
|
+
// Tool call fields (OpenCode uses 'tool_use' type)
|
|
150
|
+
name?: string
|
|
151
|
+
input?: unknown
|
|
152
|
+
// Tool result fields (OpenCode uses 'tool_result' type)
|
|
153
|
+
tool_use_id?: string
|
|
154
|
+
content?: unknown
|
|
155
|
+
// Step fields (step-start, step-finish)
|
|
156
|
+
sessionID?: string
|
|
157
|
+
messageID?: string
|
|
158
|
+
reason?: string
|
|
159
|
+
cost?: number
|
|
160
|
+
tokens?: {
|
|
161
|
+
input: number
|
|
162
|
+
output: number
|
|
163
|
+
reasoning?: number
|
|
164
|
+
cache?: { read: number; write: number }
|
|
165
|
+
}
|
|
166
|
+
// Generic catch-all
|
|
167
|
+
[key: string]: unknown
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Metadata about the OpenCode response.
|
|
172
|
+
*/
|
|
173
|
+
export interface OpenCodeResponseMetadata {
|
|
174
|
+
modelID?: string
|
|
175
|
+
providerID?: string
|
|
176
|
+
tokens?: { input: number; output: number }
|
|
177
|
+
timing?: { created: number; completed?: number }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Extract all parts from OpenCode response for verbose debugging.
|
|
182
|
+
*/
|
|
183
|
+
export function extractAllPartsFromResponse(result: unknown): OpenCodeResponsePart[] {
|
|
184
|
+
if (!result || typeof result !== 'object') return []
|
|
185
|
+
|
|
186
|
+
const message = result as { parts?: OpenCodeResponsePart[] }
|
|
187
|
+
return message.parts || []
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Extract metadata (model, tokens, timing) from OpenCode response.
|
|
192
|
+
*/
|
|
193
|
+
export function extractMetadataFromResponse(result: unknown): OpenCodeResponseMetadata | null {
|
|
194
|
+
if (!result || typeof result !== 'object') return null
|
|
195
|
+
|
|
196
|
+
const message = result as {
|
|
197
|
+
info?: {
|
|
198
|
+
modelID?: string
|
|
199
|
+
providerID?: string
|
|
200
|
+
tokens?: { input: number; output: number }
|
|
201
|
+
time?: { created: number; completed?: number }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!message.info) return null
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
modelID: message.info.modelID,
|
|
209
|
+
providerID: message.info.providerID,
|
|
210
|
+
tokens: message.info.tokens,
|
|
211
|
+
timing: message.info.time,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Event types emitted during streaming message handling.
|
|
217
|
+
*/
|
|
218
|
+
export type OpenCodeStreamEvent =
|
|
219
|
+
| { type: 'thinking' }
|
|
220
|
+
| { type: 'text'; content: string }
|
|
221
|
+
| { type: 'tool-call'; id: string; toolName: string; args: unknown }
|
|
222
|
+
| { type: 'tool-result'; id: string; result: unknown }
|
|
223
|
+
| { type: 'question'; question: OpenCodeQuestion }
|
|
224
|
+
| { type: 'metadata'; model?: string; provider?: string; tokens?: { input: number; output: number }; durationMs?: number }
|
|
225
|
+
| { type: 'debug'; partType: string; data: unknown }
|
|
226
|
+
| { type: 'done'; sessionId: string }
|
|
227
|
+
| { type: 'error'; error: string }
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Handle OpenCode message with real-time SSE streaming.
|
|
231
|
+
* Uses OpenCode's /event SSE endpoint for live updates.
|
|
232
|
+
*
|
|
233
|
+
* OpenCode does agentic loops - it may generate multiple assistant messages
|
|
234
|
+
* with tool calls in between. We complete only when the session becomes "idle"
|
|
235
|
+
* after being "busy", indicating the full agentic loop is done.
|
|
236
|
+
*/
|
|
237
|
+
export async function handleOpenCodeMessageStreaming(
|
|
238
|
+
request: OpenCodeTestRequest,
|
|
239
|
+
onEvent: (event: OpenCodeStreamEvent) => Promise<void>
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const client = getClient()
|
|
242
|
+
const { message, sessionId, model } = request
|
|
243
|
+
const startTime = Date.now()
|
|
244
|
+
|
|
245
|
+
if (!message) {
|
|
246
|
+
await onEvent({ type: 'error', error: 'Message is required' })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
// Create or get session
|
|
252
|
+
let session
|
|
253
|
+
if (sessionId) {
|
|
254
|
+
session = await client.getSession(sessionId)
|
|
255
|
+
} else {
|
|
256
|
+
session = await client.createSession()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const targetSessionId = session.id
|
|
260
|
+
let unsubscribe: (() => void) | null = null
|
|
261
|
+
let emittedThinking = false
|
|
262
|
+
let wasBusy = false // Track if session was ever busy
|
|
263
|
+
let resolved = false // Track if we've already completed
|
|
264
|
+
let lastActivityTime = Date.now() // Track last event for heartbeat
|
|
265
|
+
let heartbeatInterval: NodeJS.Timeout | null = null
|
|
266
|
+
let lastMetadata: {
|
|
267
|
+
model?: string
|
|
268
|
+
provider?: string
|
|
269
|
+
tokens?: { input: number; output: number }
|
|
270
|
+
} | null = null
|
|
271
|
+
|
|
272
|
+
// Helper to clean up resources
|
|
273
|
+
const cleanup = () => {
|
|
274
|
+
if (heartbeatInterval) {
|
|
275
|
+
clearInterval(heartbeatInterval)
|
|
276
|
+
heartbeatInterval = null
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Set up SSE subscription for real-time events
|
|
281
|
+
const eventPromise = new Promise<void>((resolve, reject) => {
|
|
282
|
+
const timeout = setTimeout(() => {
|
|
283
|
+
cleanup()
|
|
284
|
+
unsubscribe?.()
|
|
285
|
+
reject(new Error('OpenCode request timed out'))
|
|
286
|
+
}, 300000) // 5 minute timeout for complex agentic tasks
|
|
287
|
+
|
|
288
|
+
// Heartbeat: Check every second for completion conditions
|
|
289
|
+
heartbeatInterval = setInterval(async () => {
|
|
290
|
+
if (resolved) return
|
|
291
|
+
|
|
292
|
+
const idleTime = Date.now() - lastActivityTime
|
|
293
|
+
|
|
294
|
+
// If no activity for 5 seconds and we were busy, check session status
|
|
295
|
+
if (idleTime >= 5000 && wasBusy && !resolved) {
|
|
296
|
+
try {
|
|
297
|
+
// Check actual session status before completing
|
|
298
|
+
const status = await client.getSessionStatus(targetSessionId)
|
|
299
|
+
|
|
300
|
+
if (status.status === 'busy') {
|
|
301
|
+
// Session is still busy - wait
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (status.status === 'waiting' && status.questionId) {
|
|
306
|
+
// Session is waiting for a question answer
|
|
307
|
+
const questions = await client.getPendingQuestions()
|
|
308
|
+
const sessionQuestion = questions.find((q) => q.id === status.questionId)
|
|
309
|
+
if (sessionQuestion) {
|
|
310
|
+
await onEvent({ type: 'question', question: sessionQuestion })
|
|
311
|
+
lastActivityTime = Date.now() // Reset timer after emitting question
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check for any pending questions for this session
|
|
317
|
+
const questions = await client.getPendingQuestions()
|
|
318
|
+
const sessionQuestion = questions.find((q) => q.sessionID === targetSessionId)
|
|
319
|
+
|
|
320
|
+
if (sessionQuestion) {
|
|
321
|
+
await onEvent({ type: 'question', question: sessionQuestion })
|
|
322
|
+
lastActivityTime = Date.now() // Reset timer after emitting question
|
|
323
|
+
} else if (status.status === 'idle') {
|
|
324
|
+
// Session is explicitly idle and no questions - complete
|
|
325
|
+
resolved = true
|
|
326
|
+
try {
|
|
327
|
+
await onEvent({ type: 'done', sessionId: targetSessionId })
|
|
328
|
+
} catch (err) {
|
|
329
|
+
console.error('[OpenCode SSE] Heartbeat: Failed to emit done event:', err)
|
|
330
|
+
}
|
|
331
|
+
cleanup()
|
|
332
|
+
clearTimeout(timeout)
|
|
333
|
+
unsubscribe?.()
|
|
334
|
+
resolve()
|
|
335
|
+
}
|
|
336
|
+
// Status is 'unknown' or something else - wait for SSE events
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error('[OpenCode SSE] Heartbeat error:', err)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}, 1000)
|
|
342
|
+
|
|
343
|
+
unsubscribe = client.subscribeToEvents(
|
|
344
|
+
async (sseEvent) => {
|
|
345
|
+
try {
|
|
346
|
+
const { type, properties } = sseEvent
|
|
347
|
+
|
|
348
|
+
// Update activity timestamp for heartbeat
|
|
349
|
+
lastActivityTime = Date.now()
|
|
350
|
+
|
|
351
|
+
// Filter events for our session
|
|
352
|
+
const eventSessionId =
|
|
353
|
+
(properties.sessionID as string) ||
|
|
354
|
+
(properties.info as { sessionID?: string })?.sessionID ||
|
|
355
|
+
(properties.part as { sessionID?: string })?.sessionID ||
|
|
356
|
+
(properties.question as { sessionID?: string })?.sessionID ||
|
|
357
|
+
(properties.session as { id?: string })?.id ||
|
|
358
|
+
(properties.status as { sessionID?: string })?.sessionID
|
|
359
|
+
|
|
360
|
+
if (eventSessionId && eventSessionId !== targetSessionId) {
|
|
361
|
+
return // Ignore events from other sessions
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
switch (type) {
|
|
365
|
+
case 'question.asked': {
|
|
366
|
+
// OpenCode is asking a question - use the data directly from the SSE event
|
|
367
|
+
await onEvent({ type: 'debug', partType: 'question-asked', data: properties })
|
|
368
|
+
|
|
369
|
+
// The question data is in properties.question (from SSE event)
|
|
370
|
+
// This is more reliable than fetching from API which may return incomplete data
|
|
371
|
+
const questionFromEvent = properties.question as OpenCodeQuestion | undefined
|
|
372
|
+
|
|
373
|
+
if (questionFromEvent && questionFromEvent.sessionID === targetSessionId) {
|
|
374
|
+
await onEvent({ type: 'question', question: questionFromEvent })
|
|
375
|
+
} else {
|
|
376
|
+
// Fallback to fetching from API if event doesn't have full question
|
|
377
|
+
const questions = await client.getPendingQuestions()
|
|
378
|
+
const sessionQuestion = questions.find((q) => q.sessionID === targetSessionId)
|
|
379
|
+
|
|
380
|
+
if (sessionQuestion) {
|
|
381
|
+
await onEvent({ type: 'question', question: sessionQuestion })
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
break
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
case 'session.status': {
|
|
388
|
+
const status = properties.status as { type: string; questionId?: string }
|
|
389
|
+
|
|
390
|
+
if (status?.type === 'busy') {
|
|
391
|
+
wasBusy = true
|
|
392
|
+
if (!emittedThinking) {
|
|
393
|
+
emittedThinking = true
|
|
394
|
+
await onEvent({ type: 'thinking' })
|
|
395
|
+
}
|
|
396
|
+
} else if (status?.type === 'waiting' && !resolved) {
|
|
397
|
+
// Session is waiting for user to answer a question
|
|
398
|
+
const questions = await client.getPendingQuestions()
|
|
399
|
+
const sessionQuestion = status.questionId
|
|
400
|
+
? questions.find((q) => q.id === status.questionId)
|
|
401
|
+
: questions.find((q) => q.sessionID === targetSessionId)
|
|
402
|
+
|
|
403
|
+
if (sessionQuestion) {
|
|
404
|
+
await onEvent({ type: 'question', question: sessionQuestion })
|
|
405
|
+
lastActivityTime = Date.now()
|
|
406
|
+
}
|
|
407
|
+
} else if (status?.type === 'idle' && wasBusy && !resolved) {
|
|
408
|
+
// Session went from busy to idle - check if there are pending questions
|
|
409
|
+
const endTime = Date.now()
|
|
410
|
+
|
|
411
|
+
// Emit final metadata if we have it
|
|
412
|
+
if (lastMetadata) {
|
|
413
|
+
await onEvent({
|
|
414
|
+
type: 'metadata',
|
|
415
|
+
model: lastMetadata.model,
|
|
416
|
+
provider: lastMetadata.provider,
|
|
417
|
+
tokens: lastMetadata.tokens,
|
|
418
|
+
durationMs: endTime - startTime,
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Check for pending questions before declaring done
|
|
423
|
+
const questions = await client.getPendingQuestions()
|
|
424
|
+
const sessionQuestion = questions.find((q) => q.sessionID === targetSessionId)
|
|
425
|
+
|
|
426
|
+
if (sessionQuestion) {
|
|
427
|
+
// Question found - emit it but keep stream open for answer
|
|
428
|
+
await onEvent({ type: 'question', question: sessionQuestion })
|
|
429
|
+
// Reset activity time so heartbeat doesn't close prematurely
|
|
430
|
+
lastActivityTime = Date.now()
|
|
431
|
+
// Don't set resolved - let heartbeat handle completion after user answers
|
|
432
|
+
} else {
|
|
433
|
+
// No questions found - but give OpenCode a moment to register one
|
|
434
|
+
// (race condition prevention)
|
|
435
|
+
setTimeout(async () => {
|
|
436
|
+
try {
|
|
437
|
+
if (resolved) {
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Check one more time for questions
|
|
442
|
+
const finalQuestions = await client.getPendingQuestions()
|
|
443
|
+
const finalQuestion = finalQuestions.find((q) => q.sessionID === targetSessionId)
|
|
444
|
+
|
|
445
|
+
if (finalQuestion) {
|
|
446
|
+
await onEvent({ type: 'question', question: finalQuestion })
|
|
447
|
+
lastActivityTime = Date.now()
|
|
448
|
+
} else {
|
|
449
|
+
// Truly idle - complete the stream
|
|
450
|
+
resolved = true
|
|
451
|
+
await onEvent({ type: 'done', sessionId: targetSessionId })
|
|
452
|
+
cleanup()
|
|
453
|
+
clearTimeout(timeout)
|
|
454
|
+
unsubscribe?.()
|
|
455
|
+
resolve()
|
|
456
|
+
}
|
|
457
|
+
} catch (err) {
|
|
458
|
+
console.error('[OpenCode SSE] Error in timeout callback:', err)
|
|
459
|
+
// Still try to complete even if there was an error
|
|
460
|
+
if (!resolved) {
|
|
461
|
+
resolved = true
|
|
462
|
+
try {
|
|
463
|
+
await onEvent({ type: 'done', sessionId: targetSessionId })
|
|
464
|
+
} catch (e2) {
|
|
465
|
+
console.error('[OpenCode SSE] Failed to emit done event:', e2)
|
|
466
|
+
}
|
|
467
|
+
cleanup()
|
|
468
|
+
clearTimeout(timeout)
|
|
469
|
+
unsubscribe?.()
|
|
470
|
+
resolve()
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}, 2000)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
break
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
case 'message.updated': {
|
|
480
|
+
const info = properties.info as {
|
|
481
|
+
id: string
|
|
482
|
+
role: string
|
|
483
|
+
time?: { completed?: number }
|
|
484
|
+
modelID?: string
|
|
485
|
+
providerID?: string
|
|
486
|
+
tokens?: { input: number; output: number }
|
|
487
|
+
error?: { name: string; message?: string }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (info.role === 'assistant') {
|
|
491
|
+
// Check for error
|
|
492
|
+
if (info.error) {
|
|
493
|
+
cleanup()
|
|
494
|
+
clearTimeout(timeout)
|
|
495
|
+
unsubscribe?.()
|
|
496
|
+
await onEvent({
|
|
497
|
+
type: 'error',
|
|
498
|
+
error: `${info.error.name}: ${info.error.message || 'Unknown error'}`,
|
|
499
|
+
})
|
|
500
|
+
resolve()
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Track metadata from completed messages
|
|
505
|
+
if (info.time?.completed) {
|
|
506
|
+
lastMetadata = {
|
|
507
|
+
model: info.modelID,
|
|
508
|
+
provider: info.providerID,
|
|
509
|
+
tokens: info.tokens,
|
|
510
|
+
}
|
|
511
|
+
// Emit intermediate metadata for visibility
|
|
512
|
+
await onEvent({
|
|
513
|
+
type: 'debug',
|
|
514
|
+
partType: 'message-completed',
|
|
515
|
+
data: { messageId: info.id, tokens: info.tokens },
|
|
516
|
+
})
|
|
517
|
+
// Note: Completion is now handled by heartbeat interval
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
break
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
case 'message.part.updated': {
|
|
524
|
+
const part = properties.part as {
|
|
525
|
+
type: string
|
|
526
|
+
text?: string
|
|
527
|
+
name?: string
|
|
528
|
+
input?: unknown
|
|
529
|
+
tool_use_id?: string
|
|
530
|
+
content?: unknown
|
|
531
|
+
id: string
|
|
532
|
+
}
|
|
533
|
+
const delta = properties.delta as string | undefined
|
|
534
|
+
|
|
535
|
+
switch (part.type) {
|
|
536
|
+
case 'text':
|
|
537
|
+
// Use delta for streaming text if available
|
|
538
|
+
if (delta) {
|
|
539
|
+
await onEvent({ type: 'text', content: delta })
|
|
540
|
+
}
|
|
541
|
+
break
|
|
542
|
+
case 'tool_use':
|
|
543
|
+
if (part.name) {
|
|
544
|
+
await onEvent({
|
|
545
|
+
type: 'tool-call',
|
|
546
|
+
id: part.id,
|
|
547
|
+
toolName: part.name,
|
|
548
|
+
args: part.input,
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
break
|
|
552
|
+
case 'tool_result':
|
|
553
|
+
await onEvent({
|
|
554
|
+
type: 'tool-result',
|
|
555
|
+
id: part.tool_use_id || part.id,
|
|
556
|
+
result: part.content,
|
|
557
|
+
})
|
|
558
|
+
break
|
|
559
|
+
case 'step-start':
|
|
560
|
+
case 'step-finish':
|
|
561
|
+
await onEvent({ type: 'debug', partType: part.type, data: part })
|
|
562
|
+
break
|
|
563
|
+
}
|
|
564
|
+
break
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
console.error('[OpenCode SSE] Error processing event:', err)
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
(error) => {
|
|
572
|
+
clearTimeout(timeout)
|
|
573
|
+
reject(error)
|
|
574
|
+
}
|
|
575
|
+
)
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
// Send message (don't await - let SSE handle the response via events)
|
|
579
|
+
// We only catch errors here - successful completion is signaled via SSE session.status: idle
|
|
580
|
+
client.sendMessage(session.id, message, { model }).catch((err) => {
|
|
581
|
+
// Log send errors - SSE should also receive an error event
|
|
582
|
+
console.error('[OpenCode] Send error (SSE should handle):', err)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
// Wait for SSE to indicate completion (session.status: idle or error)
|
|
586
|
+
await eventPromise
|
|
587
|
+
} catch (error) {
|
|
588
|
+
await onEvent({
|
|
589
|
+
type: 'error',
|
|
590
|
+
error: error instanceof Error ? error.message : 'OpenCode request failed',
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Answer a pending question and continue processing.
|
|
597
|
+
* Uses polling to check for completion/next question.
|
|
598
|
+
*/
|
|
599
|
+
export async function handleOpenCodeAnswer(
|
|
600
|
+
questionId: string,
|
|
601
|
+
answer: number,
|
|
602
|
+
sessionId: string,
|
|
603
|
+
onEvent: (event: OpenCodeStreamEvent) => Promise<void>
|
|
604
|
+
): Promise<void> {
|
|
605
|
+
const client = getClient()
|
|
606
|
+
|
|
607
|
+
try {
|
|
608
|
+
// Answer the question
|
|
609
|
+
await client.answerQuestion(questionId, answer)
|
|
610
|
+
await onEvent({ type: 'thinking' })
|
|
611
|
+
|
|
612
|
+
// Poll for completion using session status (max 20 seconds for same-question wait, 60 seconds total)
|
|
613
|
+
const maxAttempts = 30
|
|
614
|
+
const pollInterval = 2000
|
|
615
|
+
let sameQuestionWaitCount = 0
|
|
616
|
+
const maxSameQuestionWait = 5 // Give up after 10 seconds of waiting on same question
|
|
617
|
+
|
|
618
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
619
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
|
620
|
+
|
|
621
|
+
// Check session status - most reliable way to know if processing is done
|
|
622
|
+
const status = await client.getSessionStatus(sessionId)
|
|
623
|
+
|
|
624
|
+
if (status.status === 'idle' || status.status === 'unknown') {
|
|
625
|
+
// Session is idle or unknown - processing complete
|
|
626
|
+
await onEvent({ type: 'done', sessionId })
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (status.status === 'waiting' && status.questionId && status.questionId !== questionId) {
|
|
631
|
+
// A new question appeared - fetch and emit it
|
|
632
|
+
const allQuestions = await client.getPendingQuestions()
|
|
633
|
+
const newQuestion = allQuestions.find((q) => q.id === status.questionId)
|
|
634
|
+
if (newQuestion) {
|
|
635
|
+
await onEvent({ type: 'question', question: newQuestion })
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// If waiting on the same question we answered, track how long
|
|
641
|
+
if (status.status === 'waiting' && status.questionId === questionId) {
|
|
642
|
+
sameQuestionWaitCount++
|
|
643
|
+
if (sameQuestionWaitCount >= maxSameQuestionWait) {
|
|
644
|
+
// OpenCode didn't properly clear the question - assume answered and complete
|
|
645
|
+
await onEvent({ type: 'done', sessionId })
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
// Reset counter if status changed
|
|
650
|
+
sameQuestionWaitCount = 0
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Session is busy - keep polling
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Timeout - assume complete
|
|
657
|
+
await onEvent({ type: 'done', sessionId })
|
|
658
|
+
} catch (error) {
|
|
659
|
+
console.error('[OpenCode Answer] Error:', error)
|
|
660
|
+
await onEvent({
|
|
661
|
+
type: 'error',
|
|
662
|
+
error: error instanceof Error ? error.message : 'Failed to answer question',
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Get pending questions for a session.
|
|
669
|
+
*/
|
|
670
|
+
export async function getPendingQuestions(): Promise<OpenCodeQuestion[]> {
|
|
671
|
+
const client = getClient()
|
|
672
|
+
return client.getPendingQuestions()
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Re-export the question type
|
|
676
|
+
export type { OpenCodeQuestion }
|