codeblog-app 2.1.7 → 2.2.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "codeblog-app",
4
- "version": "2.1.7",
4
+ "version": "2.2.0",
5
5
  "description": "CLI client for CodeBlog — the forum where AI writes the posts",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -56,11 +56,11 @@
56
56
  "typescript": "5.8.2"
57
57
  },
58
58
  "optionalDependencies": {
59
- "codeblog-app-darwin-arm64": "2.1.7",
60
- "codeblog-app-darwin-x64": "2.1.7",
61
- "codeblog-app-linux-arm64": "2.1.7",
62
- "codeblog-app-linux-x64": "2.1.7",
63
- "codeblog-app-windows-x64": "2.1.7"
59
+ "codeblog-app-darwin-arm64": "2.2.0",
60
+ "codeblog-app-darwin-x64": "2.2.0",
61
+ "codeblog-app-linux-arm64": "2.2.0",
62
+ "codeblog-app-linux-x64": "2.2.0",
63
+ "codeblog-app-windows-x64": "2.2.0"
64
64
  },
65
65
  "dependencies": {
66
66
  "@ai-sdk/anthropic": "^3.0.44",
@@ -5,12 +5,17 @@ const mockCallToolJSON = mock((name: string, args: Record<string, unknown>) =>
5
5
  Promise.resolve({ ok: true, tool: name }),
6
6
  )
7
7
 
8
+ const mockListTools = mock(() =>
9
+ Promise.resolve({ tools: [] }),
10
+ )
11
+
8
12
  mock.module("../../mcp/client", () => ({
9
13
  McpBridge: {
10
14
  callTool: mock((name: string, args: Record<string, unknown>) =>
11
15
  Promise.resolve(JSON.stringify({ ok: true, tool: name })),
12
16
  ),
13
17
  callToolJSON: mockCallToolJSON,
18
+ listTools: mockListTools,
14
19
  disconnect: mock(() => Promise.resolve()),
15
20
  },
16
21
  }))
@@ -25,10 +30,23 @@ function makeStreamResult() {
25
30
  }
26
31
  }
27
32
 
33
+ function makeToolCallStreamResult() {
34
+ return {
35
+ fullStream: (async function* () {
36
+ yield { type: "tool-call", toolName: "scan_sessions", args: { limit: 5 } }
37
+ yield { type: "tool-result", toolName: "scan_sessions", result: { sessions: [] } }
38
+ yield { type: "text-delta", textDelta: "Done scanning." }
39
+ })(),
40
+ }
41
+ }
42
+
43
+ let streamFactory = () => makeStreamResult()
44
+
28
45
  mock.module("ai", () => ({
29
- streamText: () => makeStreamResult(),
30
- ModelMessage: class {},
46
+ streamText: () => streamFactory(),
47
+ stepCountIs: (n: number) => ({ type: "step-count", count: n }),
31
48
  tool: (config: any) => config,
49
+ jsonSchema: (schema: any) => schema,
32
50
  }))
33
51
 
34
52
  mock.module("../provider", () => ({
@@ -43,6 +61,7 @@ const { AIChat } = await import("../chat")
43
61
  describe("AIChat", () => {
44
62
  beforeEach(() => {
45
63
  mockCallToolJSON.mockClear()
64
+ streamFactory = () => makeStreamResult()
46
65
  })
47
66
 
48
67
  // ---------------------------------------------------------------------------
@@ -99,6 +118,56 @@ describe("AIChat", () => {
99
118
  // Should not throw — system messages are filtered
100
119
  })
101
120
 
121
+ // ---------------------------------------------------------------------------
122
+ // stream() with tool calls
123
+ // ---------------------------------------------------------------------------
124
+
125
+ test("stream dispatches onToolCall and onToolResult callbacks", async () => {
126
+ streamFactory = () => makeToolCallStreamResult()
127
+
128
+ const toolCalls: Array<{ name: string; args: unknown }> = []
129
+ const toolResults: Array<{ name: string; result: unknown }> = []
130
+ const tokens: string[] = []
131
+
132
+ await AIChat.stream(
133
+ [{ role: "user", content: "scan my sessions" }],
134
+ {
135
+ onToken: (t) => tokens.push(t),
136
+ onToolCall: (name, args) => toolCalls.push({ name, args }),
137
+ onToolResult: (name, result) => toolResults.push({ name, result }),
138
+ onFinish: () => {},
139
+ },
140
+ )
141
+
142
+ expect(toolCalls).toEqual([{ name: "scan_sessions", args: { limit: 5 } }])
143
+ expect(toolResults).toEqual([{ name: "scan_sessions", result: { sessions: [] } }])
144
+ expect(tokens).toEqual(["Done scanning."])
145
+ })
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // stream() error handling
149
+ // ---------------------------------------------------------------------------
150
+
151
+ test("stream calls onError when error event is received", async () => {
152
+ streamFactory = () => ({
153
+ fullStream: (async function* () {
154
+ yield { type: "error", error: new Error("test error") }
155
+ })(),
156
+ })
157
+
158
+ const errors: Error[] = []
159
+ await AIChat.stream(
160
+ [{ role: "user", content: "test" }],
161
+ {
162
+ onError: (err) => errors.push(err),
163
+ onFinish: () => {},
164
+ },
165
+ )
166
+
167
+ expect(errors).toHaveLength(1)
168
+ expect(errors[0]!.message).toBe("test error")
169
+ })
170
+
102
171
  // ---------------------------------------------------------------------------
103
172
  // generate()
104
173
  // ---------------------------------------------------------------------------
@@ -143,13 +143,27 @@ describe("AIProvider", () => {
143
143
  // available
144
144
  // ---------------------------------------------------------------------------
145
145
 
146
- test("available returns all builtin models with hasKey status", async () => {
146
+ test("available returns at least builtin models with hasKey status", async () => {
147
147
  const models = await AIProvider.available()
148
- expect(models).toHaveLength(7)
148
+ expect(models.length).toBeGreaterThanOrEqual(7)
149
149
  for (const entry of models) {
150
150
  expect(entry.model).toBeDefined()
151
151
  expect(typeof entry.hasKey).toBe("boolean")
152
152
  }
153
+ // The first 7 should always be builtins
154
+ const builtinCount = models.filter((m) => AIProvider.BUILTIN_MODELS[m.model.id]).length
155
+ expect(builtinCount).toBe(7)
156
+ })
157
+
158
+ test("available includes openai-compatible remote models when configured", async () => {
159
+ // With a valid key and base URL from config, remote models should be fetched.
160
+ // This test verifies that available() attempts to include openai-compatible models.
161
+ // Use localhost to ensure fast failure (connection refused) instead of DNS timeout.
162
+ process.env.OPENAI_COMPATIBLE_API_KEY = "sk-test"
163
+ process.env.OPENAI_COMPATIBLE_BASE_URL = "http://127.0.0.1:1"
164
+ const models = await AIProvider.available()
165
+ // Should still return at least builtins even if remote fetch fails
166
+ expect(models.length).toBeGreaterThanOrEqual(7)
153
167
  })
154
168
 
155
169
  // ---------------------------------------------------------------------------
@@ -1,23 +1,55 @@
1
- import { describe, test, expect } from "bun:test"
2
- import { getChatTools, TOOL_LABELS, clearChatToolsCache } from "../tools"
1
+ import { describe, test, expect, mock } from "bun:test"
3
2
 
4
- describe("AI Tools (dynamic MCP discovery)", () => {
5
- // These tests require a running MCP server subprocess.
6
- // getChatTools() connects to codeblog-mcp via stdio and calls listTools().
3
+ // Mock MCP bridge so tests don't need a running MCP server
4
+ const MOCK_MCP_TOOLS = [
5
+ {
6
+ name: "scan_sessions",
7
+ description: "Scan IDE sessions for coding activity",
8
+ inputSchema: { type: "object", properties: { limit: { type: "number" } } },
9
+ },
10
+ {
11
+ name: "my_dashboard",
12
+ description: "Show the user dashboard with stats",
13
+ inputSchema: {},
14
+ },
15
+ {
16
+ name: "browse_posts",
17
+ description: "Browse posts on the forum with filters",
18
+ inputSchema: { type: "object", properties: { page: { type: "number" }, tag: { type: "string" } } },
19
+ },
20
+ ]
21
+
22
+ mock.module("../../mcp/client", () => ({
23
+ McpBridge: {
24
+ listTools: mock(() => Promise.resolve({ tools: MOCK_MCP_TOOLS })),
25
+ callToolJSON: mock((name: string) => Promise.resolve({ ok: true, tool: name })),
26
+ },
27
+ }))
28
+
29
+ mock.module("ai", () => ({
30
+ tool: (config: any) => config,
31
+ jsonSchema: (schema: any) => schema,
32
+ }))
33
+
34
+ const { getChatTools, TOOL_LABELS, clearChatToolsCache } = await import("../tools")
7
35
 
36
+ describe("AI Tools (dynamic MCP discovery)", () => {
8
37
  let chatTools: Record<string, any>
9
38
 
10
39
  test("getChatTools() discovers tools from MCP server", async () => {
11
40
  clearChatToolsCache()
12
41
  chatTools = await getChatTools()
13
42
  const names = Object.keys(chatTools)
14
- expect(names.length).toBeGreaterThanOrEqual(20)
43
+ expect(names.length).toBe(MOCK_MCP_TOOLS.length)
44
+ expect(names).toContain("scan_sessions")
45
+ expect(names).toContain("my_dashboard")
46
+ expect(names).toContain("browse_posts")
15
47
  })
16
48
 
17
- test("each tool has parameters and execute", () => {
49
+ test("each tool has inputSchema and execute", () => {
18
50
  for (const [name, t] of Object.entries(chatTools)) {
19
51
  const tool = t as any
20
- expect(tool.parameters).toBeDefined()
52
+ expect(tool.inputSchema).toBeDefined()
21
53
  expect(tool.execute).toBeDefined()
22
54
  expect(typeof tool.execute).toBe("function")
23
55
  }
@@ -32,6 +64,13 @@ describe("AI Tools (dynamic MCP discovery)", () => {
32
64
  }
33
65
  })
34
66
 
67
+ test("normalizeToolSchema adds type:object to empty schemas", async () => {
68
+ // my_dashboard has empty inputSchema {} — should be normalized
69
+ const dashboard = chatTools["my_dashboard"] as any
70
+ expect(dashboard.inputSchema.type).toBe("object")
71
+ expect(dashboard.inputSchema.properties).toEqual({})
72
+ })
73
+
35
74
  // ---------------------------------------------------------------------------
36
75
  // TOOL_LABELS tests (static fallback map)
37
76
  // ---------------------------------------------------------------------------
package/src/ai/chat.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { streamText, type ModelMessage } from "ai"
1
+ import { streamText, stepCountIs } from "ai"
2
2
  import { AIProvider } from "./provider"
3
3
  import { getChatTools } from "./tools"
4
4
  import { Log } from "../util/log"
@@ -21,6 +21,9 @@ After a tool returns results, summarize them naturally for the user.
21
21
  Write casually like a dev talking to another dev. Be specific, opinionated, and genuine.
22
22
  Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a conference paper.`
23
23
 
24
+ const MAX_TOOL_STEPS = 1
25
+ const IDLE_TIMEOUT_MS = 15_000 // 15s without any stream event → abort
26
+
24
27
  export namespace AIChat {
25
28
  export interface Message {
26
29
  role: "user" | "assistant" | "system"
@@ -38,95 +41,123 @@ export namespace AIChat {
38
41
  export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
39
42
  const model = await AIProvider.getModel(modelID)
40
43
  const tools = await getChatTools()
41
- log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
44
+ log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length, toolCount: Object.keys(tools).length })
42
45
 
43
- // Build history: only user/assistant text (tool context is added per-step below)
44
- const history: ModelMessage[] = messages
46
+ const history = messages
45
47
  .filter((m) => m.role === "user" || m.role === "assistant")
46
48
  .map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
47
49
  let full = ""
48
50
 
49
- for (let step = 0; step < 5; step++) {
50
- if (signal?.aborted) break
51
-
52
- const result = streamText({
53
- model,
54
- system: SYSTEM_PROMPT,
55
- messages: history,
56
- tools,
57
- maxSteps: 1,
58
- abortSignal: signal,
59
- } as any)
60
-
61
- const calls: Array<{ id: string; name: string; input: unknown; output: unknown }> = []
62
-
63
- try {
64
- log.info("starting fullStream iteration")
65
- for await (const part of (result as any).fullStream) {
66
- log.info("stream part", { type: part.type })
67
- if (signal?.aborted) break
68
- switch (part.type) {
69
- case "text-delta": {
70
- const delta = part.text ?? part.textDelta ?? ""
71
- if (delta) { full += delta; callbacks.onToken?.(delta) }
72
- break
73
- }
74
- case "tool-call": {
75
- const input = part.input ?? part.args
76
- callbacks.onToolCall?.(part.toolName, input)
77
- calls.push({ id: part.toolCallId, name: part.toolName, input, output: undefined })
78
- break
79
- }
80
- case "tool-result": {
81
- const output = part.output ?? part.result ?? {}
82
- const name = part.toolName
83
- callbacks.onToolResult?.(name, output)
84
- const match = calls.find((c: any) => c.id === part.toolCallId && c.output === undefined)
85
- if (match) match.output = output
86
- break
87
- }
88
- case "error": {
89
- const msg = part.error instanceof Error ? part.error.message : String(part.error)
90
- log.error("stream part error", { error: msg })
91
- callbacks.onError?.(part.error instanceof Error ? part.error : new Error(msg))
92
- break
93
- }
51
+ // Create an internal AbortController that we can trigger on idle timeout
52
+ const internalAbort = new AbortController()
53
+ const onExternalAbort = () => {
54
+ log.info("external abort signal received")
55
+ internalAbort.abort()
56
+ }
57
+ signal?.addEventListener("abort", onExternalAbort)
58
+
59
+ const result = streamText({
60
+ model,
61
+ system: SYSTEM_PROMPT,
62
+ messages: history,
63
+ tools,
64
+ stopWhen: stepCountIs(MAX_TOOL_STEPS),
65
+ toolChoice: "auto",
66
+ abortSignal: internalAbort.signal,
67
+ onStepFinish: (stepResult) => {
68
+ log.info("onStepFinish", {
69
+ stepNumber: stepResult.stepNumber,
70
+ finishReason: stepResult.finishReason,
71
+ textLength: stepResult.text?.length ?? 0,
72
+ toolCallsCount: stepResult.toolCalls?.length ?? 0,
73
+ toolResultsCount: stepResult.toolResults?.length ?? 0,
74
+ })
75
+ },
76
+ })
77
+
78
+ let partCount = 0
79
+ let toolExecuting = false
80
+ try {
81
+ // Idle timeout: if no stream events arrive for IDLE_TIMEOUT_MS, abort.
82
+ // Paused during tool execution (tools can take longer than 15s).
83
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
84
+ const resetIdle = () => {
85
+ if (idleTimer) clearTimeout(idleTimer)
86
+ if (toolExecuting) return // Don't start timer while tool is running
87
+ idleTimer = setTimeout(() => {
88
+ log.info("IDLE TIMEOUT FIRED", { partCount, fullLength: full.length })
89
+ internalAbort.abort()
90
+ }, IDLE_TIMEOUT_MS)
91
+ }
92
+ resetIdle()
93
+
94
+ for await (const part of result.fullStream) {
95
+ partCount++
96
+ if (internalAbort.signal.aborted) {
97
+ log.info("abort detected in loop, breaking", { partCount })
98
+ break
99
+ }
100
+ resetIdle()
101
+
102
+ switch (part.type) {
103
+ case "text-delta": {
104
+ const delta = (part as any).text ?? (part as any).textDelta ?? ""
105
+ if (delta) { full += delta; callbacks.onToken?.(delta) }
106
+ break
107
+ }
108
+ case "tool-call": {
109
+ log.info("tool-call", { toolName: (part as any).toolName, partCount })
110
+ // Pause idle timer — tool execution happens between tool-call and tool-result
111
+ toolExecuting = true
112
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = undefined }
113
+ callbacks.onToolCall?.((part as any).toolName, (part as any).input ?? (part as any).args)
114
+ break
94
115
  }
116
+ case "tool-result": {
117
+ log.info("tool-result", { toolName: (part as any).toolName, partCount })
118
+ toolExecuting = false
119
+ callbacks.onToolResult?.((part as any).toolName, (part as any).output ?? (part as any).result ?? {})
120
+ break
121
+ }
122
+ case "tool-error" as any: {
123
+ log.error("tool-error", { toolName: (part as any).toolName, error: String((part as any).error).slice(0, 500) })
124
+ toolExecuting = false
125
+ break
126
+ }
127
+ case "error": {
128
+ const msg = (part as any).error instanceof Error ? (part as any).error.message : String((part as any).error)
129
+ log.error("stream part error", { error: msg })
130
+ callbacks.onError?.((part as any).error instanceof Error ? (part as any).error : new Error(msg))
131
+ break
132
+ }
133
+ default:
134
+ break
95
135
  }
96
- } catch (err) {
97
- const error = err instanceof Error ? err : new Error(String(err))
98
- log.error("stream error", { error: error.message })
136
+ }
137
+
138
+ if (idleTimer) clearTimeout(idleTimer)
139
+ log.info("for-await loop exited normally", { partCount, fullLength: full.length })
140
+ } catch (err) {
141
+ const error = err instanceof Error ? err : new Error(String(err))
142
+ log.info("catch block entered", { name: error.name, message: error.message.slice(0, 200), partCount })
143
+ // Don't treat abort as a real error
144
+ if (error.name !== "AbortError") {
145
+ log.error("stream error (non-abort)", { error: error.message })
99
146
  if (callbacks.onError) callbacks.onError(error)
100
147
  else throw error
101
- return full
148
+ } else {
149
+ log.info("AbortError caught — treating as normal completion")
102
150
  }
103
-
104
- if (calls.length === 0) break
105
-
106
- // AI SDK v6 ModelMessage format
107
- history.push({
108
- role: "assistant",
109
- content: calls.map((c) => ({
110
- type: "tool-call" as const,
111
- toolCallId: c.id,
112
- toolName: c.name,
113
- input: c.input,
114
- })),
115
- } as ModelMessage)
116
-
117
- history.push({
118
- role: "tool",
119
- content: calls.map((c) => ({
120
- type: "tool-result" as const,
121
- toolCallId: c.id,
122
- toolName: c.name,
123
- output: { type: "json" as const, value: c.output ?? {} },
124
- })),
125
- } as ModelMessage)
126
-
127
- log.info("tool step done", { step, tools: calls.map((c) => c.name) })
151
+ // On abort or error, still call onFinish so UI cleans up
152
+ log.info("calling onFinish from catch", { fullLength: full.length })
153
+ callbacks.onFinish?.(full || "(No response)")
154
+ return full
155
+ } finally {
156
+ log.info("finally block", { partCount, fullLength: full.length })
157
+ signal?.removeEventListener("abort", onExternalAbort)
128
158
  }
129
159
 
160
+ log.info("calling onFinish from normal path", { fullLength: full.length })
130
161
  callbacks.onFinish?.(full || "(No response)")
131
162
  return full
132
163
  }
@@ -115,7 +115,9 @@ export namespace AIProvider {
115
115
  }
116
116
  const compatKey = await getApiKey("openai-compatible")
117
117
  if (compatKey) {
118
- result["openai-compatible"] = { name: "OpenAI Compatible", models: [], hasKey: true }
118
+ const compatBase = await getBaseUrl("openai-compatible")
119
+ const remoteModels = compatBase ? await fetchRemoteModels(compatBase, compatKey) : []
120
+ result["openai-compatible"] = { name: "OpenAI Compatible", models: remoteModels, hasKey: true }
119
121
  }
120
122
  return result
121
123
  }
@@ -175,6 +177,26 @@ export namespace AIProvider {
175
177
  const clean = baseURL.replace(/\/+$/, "")
176
178
  opts.baseURL = clean.endsWith("/v1") ? clean : `${clean}/v1`
177
179
  }
180
+ // For openai-compatible providers, normalize request body for broader compatibility
181
+ if (pkg === "@ai-sdk/openai-compatible") {
182
+ opts.transformRequestBody = (body: Record<string, any>) => {
183
+ // Remove parallel_tool_calls — many proxies/providers don't support it
184
+ delete body.parallel_tool_calls
185
+
186
+ // Ensure all tool schemas have type: "object" (required by DeepSeek/Qwen/etc.)
187
+ if (Array.isArray(body.tools)) {
188
+ for (const t of body.tools) {
189
+ const params = t?.function?.parameters
190
+ if (params && !params.type) {
191
+ params.type = "object"
192
+ if (!params.properties) params.properties = {}
193
+ }
194
+ }
195
+ }
196
+
197
+ return body
198
+ }
199
+ }
178
200
  sdk = createFn(opts)
179
201
  sdkCache.set(cacheKey, sdk)
180
202
  }
@@ -188,6 +210,22 @@ export namespace AIProvider {
188
210
  return (sdk as any)(modelID)
189
211
  }
190
212
 
213
+ async function fetchRemoteModels(base: string, key: string): Promise<string[]> {
214
+ try {
215
+ const clean = base.replace(/\/+$/, "")
216
+ const url = clean.endsWith("/v1") ? `${clean}/models` : `${clean}/v1/models`
217
+ const r = await fetch(url, {
218
+ headers: { Authorization: `Bearer ${key}` },
219
+ signal: AbortSignal.timeout(8000),
220
+ })
221
+ if (!r.ok) return []
222
+ const data = await r.json() as { data?: Array<{ id: string }> }
223
+ return data.data?.map((m) => m.id) ?? []
224
+ } catch {
225
+ return []
226
+ }
227
+ }
228
+
191
229
  function noKeyError(providerID: string): Error {
192
230
  const envKeys = PROVIDER_ENV[providerID] || []
193
231
  const envHint = envKeys[0] || `${providerID.toUpperCase().replace(/-/g, "_")}_API_KEY`
@@ -225,6 +263,19 @@ export namespace AIProvider {
225
263
  const apiKey = await getApiKey(model.providerID)
226
264
  result.push({ model, hasKey: !!apiKey })
227
265
  }
266
+ // Include remote models from openai-compatible provider
267
+ const compatKey = await getApiKey("openai-compatible")
268
+ const compatBase = await getBaseUrl("openai-compatible")
269
+ if (compatKey && compatBase) {
270
+ const remoteModels = await fetchRemoteModels(compatBase, compatKey)
271
+ for (const id of remoteModels) {
272
+ if (BUILTIN_MODELS[id]) continue
273
+ result.push({
274
+ model: { id, providerID: "openai-compatible", name: id, contextWindow: 0, outputTokens: 0 },
275
+ hasKey: true,
276
+ })
277
+ }
278
+ }
228
279
  return result
229
280
  }
230
281
 
package/src/ai/tools.ts CHANGED
@@ -53,6 +53,18 @@ function clean(obj: Record<string, unknown>): Record<string, unknown> {
53
53
  return result
54
54
  }
55
55
 
56
+ // ---------------------------------------------------------------------------
57
+ // Schema normalization: ensure all JSON schemas are valid tool input schemas.
58
+ // Some MCP tools have empty inputSchema ({}) which produces schemas without
59
+ // "type": "object", causing providers like DeepSeek/Qwen to reject them.
60
+ // ---------------------------------------------------------------------------
61
+ function normalizeToolSchema(schema: Record<string, unknown>): Record<string, unknown> {
62
+ const normalized = { ...schema }
63
+ if (!normalized.type) normalized.type = "object"
64
+ if (normalized.type === "object" && !normalized.properties) normalized.properties = {}
65
+ return normalized
66
+ }
67
+
56
68
  // ---------------------------------------------------------------------------
57
69
  // Dynamic tool discovery from MCP server
58
70
  // ---------------------------------------------------------------------------
@@ -72,12 +84,29 @@ export async function getChatTools(): Promise<Record<string, any>> {
72
84
 
73
85
  for (const t of mcpTools) {
74
86
  const name = t.name
75
- const schema = t.inputSchema as Record<string, unknown>
87
+ const rawSchema = (t.inputSchema ?? {}) as Record<string, unknown>
76
88
 
77
- tools[name] = (tool as any)({
89
+ tools[name] = tool({
78
90
  description: t.description || name,
79
- parameters: jsonSchema(schema),
80
- execute: async (args: any) => mcp(name, clean(args)),
91
+ inputSchema: jsonSchema(normalizeToolSchema(rawSchema)),
92
+ execute: async (args: any) => {
93
+ log.info("execute tool", { name, args })
94
+ try {
95
+ const result = await mcp(name, clean(args))
96
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result)
97
+ log.info("execute tool result", { name, resultType: typeof result, resultLength: resultStr.length, resultPreview: resultStr.slice(0, 300) })
98
+ // Truncate very large tool results to avoid overwhelming the LLM context
99
+ if (resultStr.length > 8000) {
100
+ log.info("truncating large tool result", { name, originalLength: resultStr.length })
101
+ return resultStr.slice(0, 8000) + "\n...(truncated)"
102
+ }
103
+ return resultStr
104
+ } catch (err) {
105
+ const msg = err instanceof Error ? err.message : String(err)
106
+ log.error("execute tool error", { name, error: msg })
107
+ return JSON.stringify({ error: msg })
108
+ }
109
+ },
81
110
  })
82
111
  }
83
112
 
@@ -74,7 +74,7 @@ describe("CLI Commands", () => {
74
74
  test("handler calls codeblog_status when --status flag", async () => {
75
75
  mockCallTool.mockImplementationOnce(() => Promise.resolve("Status: OK"))
76
76
  await (ScanCommand.handler as any)({ status: true, limit: 20 })
77
- expect(mockCallTool).toHaveBeenCalledWith("codeblog_status")
77
+ expect(mockCallTool).toHaveBeenCalledWith("codeblog_status", {})
78
78
  })
79
79
 
80
80
  test("handler passes source when provided", async () => {
@@ -156,14 +156,24 @@ export function Home(props: {
156
156
  const { AIChat } = await import("../../ai/chat")
157
157
  const { Config } = await import("../../config")
158
158
  const { AIProvider } = await import("../../ai/provider")
159
+ const { Log } = await import("../../util/log")
160
+ const sendLog = Log.create({ service: "home-send" })
159
161
  const cfg = await Config.load()
160
162
  const mid = cfg.model || AIProvider.DEFAULT_MODEL
161
163
  const allMsgs = [...prev, userMsg].filter((m) => m.role !== "tool").map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
162
164
  let full = ""
165
+ let hasToolCalls = false
166
+ let lastToolName = ""
167
+ let lastToolResult = ""
168
+ let summaryStreamActive = false
163
169
  abortCtrl = new AbortController()
170
+ sendLog.info("calling AIChat.stream", { model: mid, msgCount: allMsgs.length })
164
171
  await AIChat.stream(allMsgs, {
165
172
  onToken: (token) => { full += token; setStreamText(full) },
166
173
  onToolCall: (name) => {
174
+ hasToolCalls = true
175
+ lastToolName = name
176
+ sendLog.info("onToolCall", { name })
167
177
  // Save any accumulated text as assistant message before tool
168
178
  if (full.trim()) {
169
179
  setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
@@ -172,7 +182,12 @@ export function Home(props: {
172
182
  }
173
183
  setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[name] || name, toolName: name, toolStatus: "running" }])
174
184
  },
175
- onToolResult: (name) => {
185
+ onToolResult: (name, result) => {
186
+ sendLog.info("onToolResult", { name })
187
+ try {
188
+ const str = typeof result === "string" ? result : JSON.stringify(result)
189
+ lastToolResult = str.slice(0, 6000)
190
+ } catch { lastToolResult = "" }
176
191
  setMessages((p) => p.map((m) =>
177
192
  m.role === "tool" && m.toolName === name && m.toolStatus === "running"
178
193
  ? { ...m, toolStatus: "done" as const }
@@ -180,13 +195,55 @@ export function Home(props: {
180
195
  ))
181
196
  },
182
197
  onFinish: () => {
198
+ sendLog.info("onFinish", { fullLength: full.length, hasToolCalls, hasToolResult: !!lastToolResult })
183
199
  if (full.trim()) {
184
200
  setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
201
+ setStreamText(""); setStreaming(false)
202
+ saveChat()
203
+ } else if (hasToolCalls && lastToolResult) {
204
+ // Tool executed but model didn't summarize — send a follow-up request
205
+ // to have the model produce a natural-language summary
206
+ sendLog.info("auto-summarizing tool result", { tool: lastToolName })
207
+ full = ""
208
+ setStreamText("")
209
+ const summaryMsgs = [
210
+ ...allMsgs,
211
+ { role: "assistant" as const, content: `I used the ${lastToolName} tool. Here are the results:\n${lastToolResult}` },
212
+ { role: "user" as const, content: "Please summarize these results in a helpful, natural way." },
213
+ ]
214
+ // NOTE: intentionally not awaited — the outer await resolves here,
215
+ // but streaming state is managed by the inner callbacks.
216
+ // The finally block must NOT reset streaming in this path.
217
+ summaryStreamActive = true
218
+ AIChat.stream(summaryMsgs, {
219
+ onToken: (token) => { full += token; setStreamText(full) },
220
+ onFinish: () => {
221
+ if (full.trim()) {
222
+ setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
223
+ } else {
224
+ setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — model did not respond)" }])
225
+ }
226
+ setStreamText(""); setStreaming(false)
227
+ saveChat()
228
+ },
229
+ onError: (err) => {
230
+ sendLog.info("summary stream error", { message: err.message })
231
+ setMessages((p) => [...p, { role: "assistant", content: `Tool result received but summary failed: ${err.message}` }])
232
+ setStreamText(""); setStreaming(false)
233
+ saveChat()
234
+ },
235
+ }, mid, abortCtrl?.signal)
236
+ } else if (hasToolCalls) {
237
+ setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — no response from model)" }])
238
+ setStreamText(""); setStreaming(false)
239
+ saveChat()
240
+ } else {
241
+ setStreamText(""); setStreaming(false)
242
+ saveChat()
185
243
  }
186
- setStreamText(""); setStreaming(false)
187
- saveChat()
188
244
  },
189
245
  onError: (err) => {
246
+ sendLog.info("onError", { message: err.message })
190
247
  setMessages((p) => {
191
248
  // Mark any running tools as error
192
249
  const updated = p.map((m) =>
@@ -200,13 +257,19 @@ export function Home(props: {
200
257
  saveChat()
201
258
  },
202
259
  }, mid, abortCtrl.signal)
260
+ sendLog.info("AIChat.stream returned normally")
203
261
  abortCtrl = undefined
204
262
  } catch (err) {
205
263
  const msg = err instanceof Error ? err.message : String(err)
264
+ // Can't use sendLog here because it might not be in scope
206
265
  setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
207
- setStreamText("")
208
- setStreaming(false)
209
266
  saveChat()
267
+ } finally {
268
+ // Clean up streaming state — but NOT if a summary stream is still running
269
+ if (!summaryStreamActive) {
270
+ setStreamText("")
271
+ setStreaming(false)
272
+ }
210
273
  }
211
274
  }
212
275
 
@@ -392,7 +455,7 @@ export function Home(props: {
392
455
 
393
456
  {/* When chatting: messages fill the space */}
394
457
  <Show when={chatting()}>
395
- <box flexDirection="column" flexGrow={1} paddingTop={1} overflow="scroll">
458
+ <scrollbox flexGrow={1} paddingTop={1} stickyScroll={true} stickyStart="bottom">
396
459
  <For each={messages()}>
397
460
  {(msg) => (
398
461
  <box flexShrink={0}>
@@ -402,7 +465,7 @@ export function Home(props: {
402
465
  <text fg={theme.colors.primary} flexShrink={0}>
403
466
  <span style={{ bold: true }}>{"❯ "}</span>
404
467
  </text>
405
- <text fg={theme.colors.text}>
468
+ <text fg={theme.colors.text} wrapMode="word" flexGrow={1} flexShrink={1}>
406
469
  <span style={{ bold: true }}>{msg.content}</span>
407
470
  </text>
408
471
  </box>
@@ -424,23 +487,27 @@ export function Home(props: {
424
487
  <text fg={theme.colors.success} flexShrink={0}>
425
488
  <span style={{ bold: true }}>{"◆ "}</span>
426
489
  </text>
427
- <text fg={theme.colors.text}>{msg.content}</text>
490
+ <text fg={theme.colors.text} wrapMode="word" flexGrow={1} flexShrink={1}>{msg.content}</text>
428
491
  </box>
429
492
  </Show>
430
493
  </box>
431
494
  )}
432
495
  </For>
433
- <Show when={streaming()}>
434
- <box flexDirection="row" paddingBottom={1} flexShrink={0}>
435
- <text fg={theme.colors.success} flexShrink={0}>
436
- <span style={{ bold: true }}>{"◆ "}</span>
437
- </text>
438
- <text fg={streamText() ? theme.colors.text : theme.colors.textMuted}>
439
- {streamText() || shimmerText()}
440
- </text>
441
- </box>
442
- </Show>
443
- </box>
496
+ <box
497
+ flexDirection="row"
498
+ paddingBottom={streaming() ? 1 : 0}
499
+ flexShrink={0}
500
+ height={streaming() ? undefined : 0}
501
+ overflow="hidden"
502
+ >
503
+ <text fg={theme.colors.success} flexShrink={0}>
504
+ <span style={{ bold: true }}>{streaming() ? "◆ " : ""}</span>
505
+ </text>
506
+ <text fg={streamText() ? theme.colors.text : theme.colors.textMuted} wrapMode="word" flexGrow={1} flexShrink={1}>
507
+ {streaming() ? (streamText() || shimmerText()) : ""}
508
+ </text>
509
+ </box>
510
+ </scrollbox>
444
511
  </Show>
445
512
 
446
513
  {/* Spacer when no chat and no autocomplete */}
@@ -54,7 +54,8 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) {
54
54
  }))
55
55
  if (items.length > 0) {
56
56
  setModels(items)
57
- const curIdx = items.findIndex((m) => m.id === (cfg.model || AIProvider.DEFAULT_MODEL))
57
+ const modelId = cfg.model || AIProvider.DEFAULT_MODEL
58
+ const curIdx = items.findIndex((m) => m.id === modelId || `${m.provider}/${m.id}` === modelId)
58
59
  if (curIdx >= 0) setIdx(curIdx)
59
60
  setStatus(`${items.length} models loaded`)
60
61
  } else {
@@ -123,9 +124,11 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) {
123
124
 
124
125
  async function save(id: string) {
125
126
  try {
127
+ const item = filtered().find((m) => m.id === id)
128
+ const saveId = item && item.provider === "openai-compatible" ? `openai-compatible/${id}` : id
126
129
  const { Config } = await import("../../config")
127
- await Config.save({ model: id })
128
- props.onDone(id)
130
+ await Config.save({ model: saveId })
131
+ props.onDone(saveId)
129
132
  } catch {
130
133
  props.onDone()
131
134
  }
@@ -187,7 +190,7 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) {
187
190
  <text fg={selected() ? "#ffffff" : theme.colors.textMuted}>
188
191
  {" " + m.provider}
189
192
  </text>
190
- {m.id === current() ? (
193
+ {(m.id === current() || `${m.provider}/${m.id}` === current()) ? (
191
194
  <text fg={selected() ? "#ffffff" : theme.colors.success}>{" ← current"}</text>
192
195
  ) : null}
193
196
  </box>