codeblog-app 1.6.4 → 2.0.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.
Files changed (76) hide show
  1. package/package.json +4 -18
  2. package/src/ai/__tests__/chat.test.ts +110 -0
  3. package/src/ai/__tests__/provider.test.ts +184 -0
  4. package/src/ai/__tests__/tools.test.ts +90 -0
  5. package/src/ai/chat.ts +81 -50
  6. package/src/ai/provider.ts +24 -250
  7. package/src/ai/tools.ts +46 -281
  8. package/src/auth/oauth.ts +7 -0
  9. package/src/cli/__tests__/commands.test.ts +225 -0
  10. package/src/cli/__tests__/setup.test.ts +57 -0
  11. package/src/cli/cmd/agent.ts +102 -0
  12. package/src/cli/cmd/chat.ts +1 -1
  13. package/src/cli/cmd/comment.ts +47 -16
  14. package/src/cli/cmd/feed.ts +18 -30
  15. package/src/cli/cmd/forum.ts +123 -0
  16. package/src/cli/cmd/login.ts +9 -2
  17. package/src/cli/cmd/me.ts +202 -0
  18. package/src/cli/cmd/post.ts +6 -88
  19. package/src/cli/cmd/publish.ts +44 -23
  20. package/src/cli/cmd/scan.ts +45 -34
  21. package/src/cli/cmd/search.ts +8 -70
  22. package/src/cli/cmd/setup.ts +160 -62
  23. package/src/cli/cmd/vote.ts +29 -14
  24. package/src/cli/cmd/whoami.ts +7 -36
  25. package/src/cli/ui.ts +50 -0
  26. package/src/index.ts +80 -59
  27. package/src/mcp/__tests__/client.test.ts +149 -0
  28. package/src/mcp/__tests__/e2e.ts +327 -0
  29. package/src/mcp/__tests__/integration.ts +148 -0
  30. package/src/mcp/client.ts +148 -0
  31. package/src/api/agents.ts +0 -103
  32. package/src/api/bookmarks.ts +0 -25
  33. package/src/api/client.ts +0 -96
  34. package/src/api/debates.ts +0 -35
  35. package/src/api/feed.ts +0 -25
  36. package/src/api/notifications.ts +0 -31
  37. package/src/api/posts.ts +0 -116
  38. package/src/api/search.ts +0 -29
  39. package/src/api/tags.ts +0 -13
  40. package/src/api/trending.ts +0 -38
  41. package/src/api/users.ts +0 -8
  42. package/src/cli/cmd/agents.ts +0 -77
  43. package/src/cli/cmd/ai-publish.ts +0 -118
  44. package/src/cli/cmd/bookmark.ts +0 -27
  45. package/src/cli/cmd/bookmarks.ts +0 -42
  46. package/src/cli/cmd/dashboard.ts +0 -59
  47. package/src/cli/cmd/debate.ts +0 -89
  48. package/src/cli/cmd/delete.ts +0 -35
  49. package/src/cli/cmd/edit.ts +0 -42
  50. package/src/cli/cmd/explore.ts +0 -63
  51. package/src/cli/cmd/follow.ts +0 -34
  52. package/src/cli/cmd/myposts.ts +0 -50
  53. package/src/cli/cmd/notifications.ts +0 -65
  54. package/src/cli/cmd/tags.ts +0 -58
  55. package/src/cli/cmd/trending.ts +0 -64
  56. package/src/cli/cmd/weekly-digest.ts +0 -117
  57. package/src/publisher/index.ts +0 -139
  58. package/src/scanner/__tests__/analyzer.test.ts +0 -67
  59. package/src/scanner/__tests__/fs-utils.test.ts +0 -50
  60. package/src/scanner/__tests__/platform.test.ts +0 -27
  61. package/src/scanner/__tests__/registry.test.ts +0 -56
  62. package/src/scanner/aider.ts +0 -96
  63. package/src/scanner/analyzer.ts +0 -237
  64. package/src/scanner/claude-code.ts +0 -188
  65. package/src/scanner/codex.ts +0 -127
  66. package/src/scanner/continue-dev.ts +0 -95
  67. package/src/scanner/cursor.ts +0 -299
  68. package/src/scanner/fs-utils.ts +0 -123
  69. package/src/scanner/index.ts +0 -26
  70. package/src/scanner/platform.ts +0 -44
  71. package/src/scanner/registry.ts +0 -68
  72. package/src/scanner/types.ts +0 -62
  73. package/src/scanner/vscode-copilot.ts +0 -125
  74. package/src/scanner/warp.ts +0 -19
  75. package/src/scanner/windsurf.ts +0 -147
  76. package/src/scanner/zed.ts +0 -88
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": "1.6.4",
4
+ "version": "2.0.0",
5
5
  "description": "CLI client for CodeBlog — the forum where AI writes the posts",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "scripts": {
32
32
  "typecheck": "tsc --noEmit",
33
33
  "test": "bun test --timeout 30000",
34
- "dev": "bun run ./src/index.ts"
34
+ "dev": "bun run --watch ./src/index.ts"
35
35
  },
36
36
  "bin": {
37
37
  "codeblog": "./bin/codeblog"
@@ -63,33 +63,19 @@
63
63
  "codeblog-app-windows-x64": "1.6.0"
64
64
  },
65
65
  "dependencies": {
66
- "@ai-sdk/amazon-bedrock": "^4.0.60",
67
66
  "@ai-sdk/anthropic": "^3.0.44",
68
- "@ai-sdk/azure": "^3.0.30",
69
- "@ai-sdk/cerebras": "^2.0.33",
70
- "@ai-sdk/cohere": "^3.0.21",
71
- "@ai-sdk/deepinfra": "^2.0.34",
72
- "@ai-sdk/gateway": "^3.0.46",
73
67
  "@ai-sdk/google": "^3.0.29",
74
- "@ai-sdk/google-vertex": "^4.0.58",
75
- "@ai-sdk/groq": "^3.0.24",
76
- "@ai-sdk/mistral": "^3.0.20",
77
68
  "@ai-sdk/openai": "^3.0.29",
78
69
  "@ai-sdk/openai-compatible": "^2.0.30",
79
- "@ai-sdk/perplexity": "^3.0.19",
80
- "@ai-sdk/togetherai": "^2.0.33",
81
- "@ai-sdk/vercel": "^2.0.32",
82
- "@ai-sdk/xai": "^3.0.56",
83
- "@openrouter/ai-sdk-provider": "^2.2.3",
70
+ "@modelcontextprotocol/sdk": "^1.26.0",
84
71
  "@opentui/core": "^0.1.79",
85
72
  "@opentui/solid": "^0.1.79",
86
73
  "ai": "^6.0.86",
74
+ "codeblog-mcp": "^1.6.0",
87
75
  "drizzle-orm": "1.0.0-beta.12-a5629fb",
88
76
  "fuzzysort": "^3.1.0",
89
77
  "hono": "4.10.7",
90
- "ink": "^6.7.0",
91
78
  "open": "10.1.2",
92
- "react": "^19.2.4",
93
79
  "remeda": "^2.33.6",
94
80
  "solid-js": "^1.9.11",
95
81
  "xdg-basedir": "5.1.0",
@@ -0,0 +1,110 @@
1
+ import { describe, test, expect, mock, beforeEach } from "bun:test"
2
+
3
+ // Mock the MCP bridge for chat tests
4
+ const mockCallToolJSON = mock((name: string, args: Record<string, unknown>) =>
5
+ Promise.resolve({ ok: true, tool: name }),
6
+ )
7
+
8
+ mock.module("../../mcp/client", () => ({
9
+ McpBridge: {
10
+ callTool: mock((name: string, args: Record<string, unknown>) =>
11
+ Promise.resolve(JSON.stringify({ ok: true, tool: name })),
12
+ ),
13
+ callToolJSON: mockCallToolJSON,
14
+ disconnect: mock(() => Promise.resolve()),
15
+ },
16
+ }))
17
+
18
+ // Each call to streamText must return a FRESH async generator
19
+ function makeStreamResult() {
20
+ return {
21
+ fullStream: (async function* () {
22
+ yield { type: "text-delta", textDelta: "Hello " }
23
+ yield { type: "text-delta", textDelta: "World" }
24
+ })(),
25
+ }
26
+ }
27
+
28
+ mock.module("ai", () => ({
29
+ streamText: () => makeStreamResult(),
30
+ ModelMessage: class {},
31
+ tool: (config: any) => config,
32
+ }))
33
+
34
+ mock.module("../provider", () => ({
35
+ AIProvider: {
36
+ getModel: mock(() => Promise.resolve({ id: "test-model" })),
37
+ DEFAULT_MODEL: "test-model",
38
+ },
39
+ }))
40
+
41
+ const { AIChat } = await import("../chat")
42
+
43
+ describe("AIChat", () => {
44
+ beforeEach(() => {
45
+ mockCallToolJSON.mockClear()
46
+ })
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Message interface
50
+ // ---------------------------------------------------------------------------
51
+
52
+ test("Message type accepts user, assistant, system roles", () => {
53
+ const messages: AIChat.Message[] = [
54
+ { role: "user", content: "hello" },
55
+ { role: "assistant", content: "hi" },
56
+ { role: "system", content: "you are a bot" },
57
+ ]
58
+ expect(messages).toHaveLength(3)
59
+ })
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // stream()
63
+ // ---------------------------------------------------------------------------
64
+
65
+ test("stream calls onToken for each text delta", async () => {
66
+ const tokens: string[] = []
67
+ const result = await AIChat.stream(
68
+ [{ role: "user", content: "test" }],
69
+ {
70
+ onToken: (t) => tokens.push(t),
71
+ onFinish: () => {},
72
+ },
73
+ )
74
+ expect(tokens).toEqual(["Hello ", "World"])
75
+ expect(result).toBe("Hello World")
76
+ })
77
+
78
+ test("stream calls onFinish with full text", async () => {
79
+ let finished = ""
80
+ await AIChat.stream(
81
+ [{ role: "user", content: "test" }],
82
+ {
83
+ onFinish: (text) => { finished = text },
84
+ },
85
+ )
86
+ expect(finished).toBe("Hello World")
87
+ })
88
+
89
+ test("stream filters out system messages from history", async () => {
90
+ await AIChat.stream(
91
+ [
92
+ { role: "system", content: "ignored" },
93
+ { role: "user", content: "hello" },
94
+ { role: "assistant", content: "hi" },
95
+ { role: "user", content: "bye" },
96
+ ],
97
+ { onFinish: () => {} },
98
+ )
99
+ // Should not throw — system messages are filtered
100
+ })
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // generate()
104
+ // ---------------------------------------------------------------------------
105
+
106
+ test("generate returns the full response text", async () => {
107
+ const result = await AIChat.generate("test prompt")
108
+ expect(result).toBe("Hello World")
109
+ })
110
+ })
@@ -0,0 +1,184 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test"
2
+ import { AIProvider } from "../provider"
3
+
4
+ describe("AIProvider", () => {
5
+ const originalEnv = { ...process.env }
6
+
7
+ beforeEach(() => {
8
+ // Clean up env vars before each test
9
+ delete process.env.ANTHROPIC_API_KEY
10
+ delete process.env.ANTHROPIC_AUTH_TOKEN
11
+ delete process.env.OPENAI_API_KEY
12
+ delete process.env.GOOGLE_GENERATIVE_AI_API_KEY
13
+ delete process.env.GOOGLE_API_KEY
14
+ delete process.env.OPENAI_COMPATIBLE_API_KEY
15
+ delete process.env.ANTHROPIC_BASE_URL
16
+ delete process.env.OPENAI_BASE_URL
17
+ delete process.env.OPENAI_API_BASE
18
+ delete process.env.GOOGLE_API_BASE_URL
19
+ delete process.env.OPENAI_COMPATIBLE_BASE_URL
20
+ })
21
+
22
+ afterEach(() => {
23
+ // Restore original env
24
+ for (const key of Object.keys(process.env)) {
25
+ if (!(key in originalEnv)) delete process.env[key]
26
+ }
27
+ for (const [key, val] of Object.entries(originalEnv)) {
28
+ if (val !== undefined) process.env[key] = val
29
+ }
30
+ })
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // BUILTIN_MODELS
34
+ // ---------------------------------------------------------------------------
35
+
36
+ test("BUILTIN_MODELS has 7 models", () => {
37
+ expect(Object.keys(AIProvider.BUILTIN_MODELS)).toHaveLength(7)
38
+ })
39
+
40
+ test("each model has required fields", () => {
41
+ for (const [id, model] of Object.entries(AIProvider.BUILTIN_MODELS)) {
42
+ expect(model.id).toBe(id)
43
+ expect(model.providerID).toBeTruthy()
44
+ expect(model.name).toBeTruthy()
45
+ expect(model.contextWindow).toBeGreaterThan(0)
46
+ expect(model.outputTokens).toBeGreaterThan(0)
47
+ }
48
+ })
49
+
50
+ test("DEFAULT_MODEL is a valid builtin model", () => {
51
+ expect(AIProvider.BUILTIN_MODELS[AIProvider.DEFAULT_MODEL]).toBeDefined()
52
+ })
53
+
54
+ test("all providers are covered: anthropic, openai, google", () => {
55
+ const providerIDs = new Set(Object.values(AIProvider.BUILTIN_MODELS).map((m) => m.providerID))
56
+ expect(providerIDs.has("anthropic")).toBe(true)
57
+ expect(providerIDs.has("openai")).toBe(true)
58
+ expect(providerIDs.has("google")).toBe(true)
59
+ })
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // getApiKey
63
+ // ---------------------------------------------------------------------------
64
+
65
+ test("getApiKey returns env var when set", async () => {
66
+ process.env.ANTHROPIC_API_KEY = "sk-ant-test123"
67
+ const key = await AIProvider.getApiKey("anthropic")
68
+ expect(key).toBe("sk-ant-test123")
69
+ })
70
+
71
+ test("getApiKey checks secondary env var", async () => {
72
+ process.env.ANTHROPIC_AUTH_TOKEN = "token-test"
73
+ const key = await AIProvider.getApiKey("anthropic")
74
+ expect(key).toBe("token-test")
75
+ })
76
+
77
+ test("getApiKey returns undefined when no key set", async () => {
78
+ const key = await AIProvider.getApiKey("anthropic")
79
+ // May return undefined or a config value — just check it doesn't crash
80
+ expect(key === undefined || typeof key === "string").toBe(true)
81
+ })
82
+
83
+ test("getApiKey works for openai", async () => {
84
+ process.env.OPENAI_API_KEY = "sk-openai-test"
85
+ const key = await AIProvider.getApiKey("openai")
86
+ expect(key).toBe("sk-openai-test")
87
+ })
88
+
89
+ test("getApiKey works for google", async () => {
90
+ process.env.GOOGLE_GENERATIVE_AI_API_KEY = "aiza-test"
91
+ const key = await AIProvider.getApiKey("google")
92
+ expect(key).toBe("aiza-test")
93
+ })
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // getBaseUrl
97
+ // ---------------------------------------------------------------------------
98
+
99
+ test("getBaseUrl returns env var when set", async () => {
100
+ process.env.ANTHROPIC_BASE_URL = "https://custom.api.com"
101
+ const url = await AIProvider.getBaseUrl("anthropic")
102
+ expect(url).toBe("https://custom.api.com")
103
+ })
104
+
105
+ test("getBaseUrl returns undefined when no env var set", async () => {
106
+ const url = await AIProvider.getBaseUrl("anthropic")
107
+ expect(url === undefined || typeof url === "string").toBe(true)
108
+ })
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // hasAnyKey
112
+ // ---------------------------------------------------------------------------
113
+
114
+ test("hasAnyKey returns true when any key is set", async () => {
115
+ process.env.OPENAI_API_KEY = "sk-test"
116
+ const has = await AIProvider.hasAnyKey()
117
+ expect(has).toBe(true)
118
+ })
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // parseModel
122
+ // ---------------------------------------------------------------------------
123
+
124
+ test("parseModel splits provider/model", () => {
125
+ const result = AIProvider.parseModel("anthropic/claude-sonnet-4-20250514")
126
+ expect(result.providerID).toBe("anthropic")
127
+ expect(result.modelID).toBe("claude-sonnet-4-20250514")
128
+ })
129
+
130
+ test("parseModel handles nested slashes", () => {
131
+ const result = AIProvider.parseModel("openai/gpt-4o/latest")
132
+ expect(result.providerID).toBe("openai")
133
+ expect(result.modelID).toBe("gpt-4o/latest")
134
+ })
135
+
136
+ test("parseModel handles no slash", () => {
137
+ const result = AIProvider.parseModel("gpt-4o")
138
+ expect(result.providerID).toBe("gpt-4o")
139
+ expect(result.modelID).toBe("")
140
+ })
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // available
144
+ // ---------------------------------------------------------------------------
145
+
146
+ test("available returns all builtin models with hasKey status", async () => {
147
+ const models = await AIProvider.available()
148
+ expect(models).toHaveLength(7)
149
+ for (const entry of models) {
150
+ expect(entry.model).toBeDefined()
151
+ expect(typeof entry.hasKey).toBe("boolean")
152
+ }
153
+ })
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // getModel
157
+ // ---------------------------------------------------------------------------
158
+
159
+ test("getModel throws when no API key for builtin model", async () => {
160
+ expect(AIProvider.getModel("gpt-4o")).rejects.toThrow("No API key for openai")
161
+ })
162
+
163
+ test("getModel falls back to provider with base_url for unknown model", async () => {
164
+ // When a provider with base_url is configured, unknown models get sent there
165
+ // instead of throwing. This test verifies the fallback behavior.
166
+ // If no provider has a base_url, it would throw.
167
+ const result = AIProvider.getModel("nonexistent-model-xyz")
168
+ // Either resolves (provider with base_url available) or rejects
169
+ const settled = await Promise.allSettled([result])
170
+ expect(settled[0]!.status).toBeDefined()
171
+ })
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // listProviders
175
+ // ---------------------------------------------------------------------------
176
+
177
+ test("listProviders returns provider info", async () => {
178
+ process.env.OPENAI_API_KEY = "sk-test"
179
+ const providers = await AIProvider.listProviders()
180
+ expect(providers.openai).toBeDefined()
181
+ expect(providers.openai.hasKey).toBe(true)
182
+ expect(providers.openai.models.length).toBeGreaterThan(0)
183
+ })
184
+ })
@@ -0,0 +1,90 @@
1
+ import { describe, test, expect } from "bun:test"
2
+ import { chatTools, TOOL_LABELS } from "../tools"
3
+
4
+ describe("AI Tools", () => {
5
+ // ---------------------------------------------------------------------------
6
+ // Structural tests — verify all tools are properly exported and configured
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const expectedTools = [
10
+ "scan_sessions", "read_session", "analyze_session",
11
+ "post_to_codeblog", "auto_post", "weekly_digest",
12
+ "browse_posts", "search_posts", "read_post",
13
+ "comment_on_post", "vote_on_post", "edit_post", "delete_post", "bookmark_post",
14
+ "browse_by_tag", "trending_topics", "explore_and_engage", "join_debate",
15
+ "my_notifications",
16
+ "manage_agents", "my_posts", "my_dashboard", "follow_user",
17
+ "codeblog_status",
18
+ ]
19
+
20
+ test("exports all 24 tools", () => {
21
+ const toolNames = Object.keys(chatTools)
22
+ expect(toolNames).toHaveLength(24)
23
+ })
24
+
25
+ test("each expected tool is present in chatTools", () => {
26
+ for (const name of expectedTools) {
27
+ expect(chatTools).toHaveProperty(name)
28
+ }
29
+ })
30
+
31
+ test("each tool has parameters and execute", () => {
32
+ for (const [name, tool] of Object.entries(chatTools)) {
33
+ const t = tool as any
34
+ expect(t.parameters).toBeDefined()
35
+ expect(t.execute).toBeDefined()
36
+ expect(typeof t.execute).toBe("function")
37
+ }
38
+ })
39
+
40
+ test("each tool has a description", () => {
41
+ for (const [name, tool] of Object.entries(chatTools)) {
42
+ const t = tool as any
43
+ expect(t.description).toBeDefined()
44
+ expect(typeof t.description).toBe("string")
45
+ expect(t.description.length).toBeGreaterThan(10)
46
+ }
47
+ })
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // TOOL_LABELS tests
51
+ // ---------------------------------------------------------------------------
52
+
53
+ test("TOOL_LABELS has an entry for every chatTool", () => {
54
+ for (const name of Object.keys(chatTools)) {
55
+ expect(TOOL_LABELS).toHaveProperty(name)
56
+ expect(typeof TOOL_LABELS[name]).toBe("string")
57
+ }
58
+ })
59
+
60
+ test("TOOL_LABELS values are non-empty strings", () => {
61
+ for (const [key, label] of Object.entries(TOOL_LABELS)) {
62
+ expect(label.length).toBeGreaterThan(0)
63
+ }
64
+ })
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Parameter schema spot-checks
68
+ // ---------------------------------------------------------------------------
69
+
70
+ test("scan_sessions has optional limit and source parameters", () => {
71
+ const params = (chatTools.scan_sessions as any).parameters
72
+ // Zod schema should exist
73
+ expect(params).toBeDefined()
74
+ })
75
+
76
+ test("post_to_codeblog requires title, content, source_session", () => {
77
+ const params = (chatTools.post_to_codeblog as any).parameters
78
+ expect(params).toBeDefined()
79
+ })
80
+
81
+ test("vote_on_post requires post_id and value", () => {
82
+ const params = (chatTools.vote_on_post as any).parameters
83
+ expect(params).toBeDefined()
84
+ })
85
+
86
+ test("delete_post requires post_id and confirm", () => {
87
+ const params = (chatTools.delete_post as any).parameters
88
+ expect(params).toBeDefined()
89
+ })
90
+ })
package/src/ai/chat.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { streamText, type CoreMessage, type CoreToolMessage, type CoreAssistantMessage } from "ai"
1
+ import { streamText, type ModelMessage } from "ai"
2
2
  import { AIProvider } from "./provider"
3
3
  import { chatTools } from "./tools"
4
4
  import { Log } from "../util/log"
@@ -35,64 +35,95 @@ export namespace AIChat {
35
35
  onToolResult?: (name: string, result: unknown) => void
36
36
  }
37
37
 
38
- // Convert our simple messages to CoreMessage[] for AI SDK
39
- // Only user/assistant text messages — tool history is handled by maxSteps internally
40
- function toCoreMessages(messages: Message[]): CoreMessage[] {
41
- return messages
42
- .filter((m) => m.role === "user" || m.role === "assistant")
43
- .map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
44
- }
45
-
46
38
  export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
47
39
  const model = await AIProvider.getModel(modelID)
48
40
  log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
49
41
 
50
- const coreMessages = toCoreMessages(messages)
42
+ // Build history: only user/assistant text (tool context is added per-step below)
43
+ const history: ModelMessage[] = messages
44
+ .filter((m) => m.role === "user" || m.role === "assistant")
45
+ .map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
51
46
  let full = ""
52
47
 
53
- const result = streamText({
54
- model,
55
- system: SYSTEM_PROMPT,
56
- messages: coreMessages,
57
- tools: chatTools,
58
- maxSteps: 5,
59
- abortSignal: signal,
60
- })
61
-
62
- try {
63
- for await (const part of result.fullStream) {
64
- if (signal?.aborted) break
65
- switch (part.type) {
66
- case "text-delta": {
67
- const delta = (part as any).text ?? (part as any).textDelta ?? ""
68
- if (delta) { full += delta; callbacks.onToken?.(delta) }
69
- break
70
- }
71
- case "tool-call": {
72
- const input = (part as any).input ?? (part as any).args
73
- callbacks.onToolCall?.(part.toolName, input)
74
- break
75
- }
76
- case "tool-result": {
77
- const output = (part as any).output ?? (part as any).result ?? {}
78
- const name = (part as any).toolName
79
- callbacks.onToolResult?.(name, output)
80
- break
81
- }
82
- case "error": {
83
- const msg = part.error instanceof Error ? part.error.message : String(part.error)
84
- log.error("stream part error", { error: msg })
85
- callbacks.onError?.(part.error instanceof Error ? part.error : new Error(msg))
86
- break
48
+ for (let step = 0; step < 5; step++) {
49
+ if (signal?.aborted) break
50
+
51
+ const result = streamText({
52
+ model,
53
+ system: SYSTEM_PROMPT,
54
+ messages: history,
55
+ tools: chatTools,
56
+ maxSteps: 1,
57
+ abortSignal: signal,
58
+ } as any)
59
+
60
+ const calls: Array<{ id: string; name: string; input: unknown; output: unknown }> = []
61
+
62
+ try {
63
+ log.info("starting fullStream iteration")
64
+ for await (const part of (result as any).fullStream) {
65
+ log.info("stream part", { type: part.type })
66
+ if (signal?.aborted) break
67
+ switch (part.type) {
68
+ case "text-delta": {
69
+ const delta = part.text ?? part.textDelta ?? ""
70
+ if (delta) { full += delta; callbacks.onToken?.(delta) }
71
+ break
72
+ }
73
+ case "tool-call": {
74
+ const input = part.input ?? part.args
75
+ callbacks.onToolCall?.(part.toolName, input)
76
+ calls.push({ id: part.toolCallId, name: part.toolName, input, output: undefined })
77
+ break
78
+ }
79
+ case "tool-result": {
80
+ const output = part.output ?? part.result ?? {}
81
+ const name = part.toolName
82
+ callbacks.onToolResult?.(name, output)
83
+ const match = calls.find((c: any) => c.id === part.toolCallId && c.output === undefined)
84
+ if (match) match.output = output
85
+ break
86
+ }
87
+ case "error": {
88
+ const msg = part.error instanceof Error ? part.error.message : String(part.error)
89
+ log.error("stream part error", { error: msg })
90
+ callbacks.onError?.(part.error instanceof Error ? part.error : new Error(msg))
91
+ break
92
+ }
87
93
  }
88
94
  }
95
+ } catch (err) {
96
+ const error = err instanceof Error ? err : new Error(String(err))
97
+ log.error("stream error", { error: error.message })
98
+ if (callbacks.onError) callbacks.onError(error)
99
+ else throw error
100
+ return full
89
101
  }
90
- } catch (err) {
91
- const error = err instanceof Error ? err : new Error(String(err))
92
- log.error("stream error", { error: error.message })
93
- if (callbacks.onError) callbacks.onError(error)
94
- else throw error
95
- return full
102
+
103
+ if (calls.length === 0) break
104
+
105
+ // AI SDK v6 ModelMessage format
106
+ history.push({
107
+ role: "assistant",
108
+ content: calls.map((c) => ({
109
+ type: "tool-call" as const,
110
+ toolCallId: c.id,
111
+ toolName: c.name,
112
+ input: c.input,
113
+ })),
114
+ } as ModelMessage)
115
+
116
+ history.push({
117
+ role: "tool",
118
+ content: calls.map((c) => ({
119
+ type: "tool-result" as const,
120
+ toolCallId: c.id,
121
+ toolName: c.name,
122
+ output: { type: "json" as const, value: c.output ?? {} },
123
+ })),
124
+ } as ModelMessage)
125
+
126
+ log.info("tool step done", { step, tools: calls.map((c) => c.name) })
96
127
  }
97
128
 
98
129
  callbacks.onFinish?.(full || "(No response)")