kanna-code 0.1.4 → 0.3.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/README.md +32 -10
- package/dist/client/assets/index-Byzgv_-q.js +409 -0
- package/dist/client/assets/index-gld9RxCU.css +1 -0
- package/dist/client/index.html +2 -2
- package/package.json +5 -1
- package/src/server/agent.test.ts +541 -0
- package/src/server/agent.ts +498 -193
- package/src/server/codex-app-server-protocol.ts +440 -0
- package/src/server/codex-app-server.test.ts +1353 -0
- package/src/server/codex-app-server.ts +1328 -0
- package/src/server/discovery.test.ts +211 -0
- package/src/server/discovery.ts +292 -17
- package/src/server/event-store.ts +81 -34
- package/src/server/events.ts +25 -17
- package/src/server/generate-title.ts +32 -39
- package/src/server/harness-types.ts +19 -0
- package/src/server/provider-catalog.test.ts +34 -0
- package/src/server/provider-catalog.ts +77 -0
- package/src/server/quick-response.test.ts +86 -0
- package/src/server/quick-response.ts +124 -0
- package/src/server/read-models.test.ts +105 -0
- package/src/server/read-models.ts +5 -1
- package/src/server/server.ts +2 -2
- package/src/shared/protocol.ts +12 -2
- package/src/shared/tools.test.ts +88 -0
- package/src/shared/tools.ts +233 -0
- package/src/shared/types.ts +404 -5
- package/dist/client/assets/index-BRiM6Nxc.css +0 -1
- package/dist/client/assets/index-CjRaPaQM.js +0 -418
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { query } from "@anthropic-ai/claude-agent-sdk"
|
|
2
|
+
import { CodexAppServerManager } from "./codex-app-server"
|
|
3
|
+
|
|
4
|
+
type JsonSchema = {
|
|
5
|
+
type: "object"
|
|
6
|
+
properties: Record<string, unknown>
|
|
7
|
+
required?: readonly string[]
|
|
8
|
+
additionalProperties?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StructuredQuickResponseArgs<T> {
|
|
12
|
+
cwd: string
|
|
13
|
+
task: string
|
|
14
|
+
prompt: string
|
|
15
|
+
schema: JsonSchema
|
|
16
|
+
parse: (value: unknown) => T | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface QuickResponseAdapterArgs {
|
|
20
|
+
codexManager?: CodexAppServerManager
|
|
21
|
+
runClaudeStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
|
|
22
|
+
runCodexStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseJsonText(value: string): unknown | null {
|
|
26
|
+
const trimmed = value.trim()
|
|
27
|
+
if (!trimmed) return null
|
|
28
|
+
|
|
29
|
+
const candidates = [trimmed]
|
|
30
|
+
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)
|
|
31
|
+
if (fencedMatch?.[1]) {
|
|
32
|
+
candidates.unshift(fencedMatch[1].trim())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(candidate)
|
|
38
|
+
} catch {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknown>, "parse">): Promise<unknown | null> {
|
|
47
|
+
const q = query({
|
|
48
|
+
prompt: args.prompt,
|
|
49
|
+
options: {
|
|
50
|
+
model: "haiku",
|
|
51
|
+
tools: [],
|
|
52
|
+
systemPrompt: "",
|
|
53
|
+
effort: "low",
|
|
54
|
+
permissionMode: "bypassPermissions",
|
|
55
|
+
outputFormat: {
|
|
56
|
+
type: "json_schema",
|
|
57
|
+
schema: args.schema,
|
|
58
|
+
},
|
|
59
|
+
env: { ...process.env },
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
for await (const message of q) {
|
|
65
|
+
if ("result" in message) {
|
|
66
|
+
return (message as Record<string, unknown>).structured_output ?? null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
} finally {
|
|
71
|
+
q.close()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runCodexStructured(
|
|
76
|
+
codexManager: CodexAppServerManager,
|
|
77
|
+
args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
|
|
78
|
+
): Promise<unknown | null> {
|
|
79
|
+
const response = await codexManager.generateStructured({
|
|
80
|
+
cwd: args.cwd,
|
|
81
|
+
prompt: `${args.prompt}\n\nReturn JSON only that matches this schema:\n${JSON.stringify(args.schema, null, 2)}`,
|
|
82
|
+
})
|
|
83
|
+
if (typeof response !== "string") return null
|
|
84
|
+
return parseJsonText(response)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class QuickResponseAdapter {
|
|
88
|
+
private readonly codexManager: CodexAppServerManager
|
|
89
|
+
private readonly runClaudeStructured: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
|
|
90
|
+
private readonly runCodexStructured: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
|
|
91
|
+
|
|
92
|
+
constructor(args: QuickResponseAdapterArgs = {}) {
|
|
93
|
+
this.codexManager = args.codexManager ?? new CodexAppServerManager()
|
|
94
|
+
this.runClaudeStructured = args.runClaudeStructured ?? runClaudeStructured
|
|
95
|
+
this.runCodexStructured = args.runCodexStructured ?? ((structuredArgs) =>
|
|
96
|
+
runCodexStructured(this.codexManager, structuredArgs))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async generateStructured<T>(args: StructuredQuickResponseArgs<T>): Promise<T | null> {
|
|
100
|
+
const request = {
|
|
101
|
+
cwd: args.cwd,
|
|
102
|
+
task: args.task,
|
|
103
|
+
prompt: args.prompt,
|
|
104
|
+
schema: args.schema,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const claudeResult = await this.tryProvider(args.parse, () => this.runClaudeStructured(request))
|
|
108
|
+
if (claudeResult !== null) return claudeResult
|
|
109
|
+
|
|
110
|
+
return await this.tryProvider(args.parse, () => this.runCodexStructured(request))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async tryProvider<T>(
|
|
114
|
+
parse: (value: unknown) => T | null,
|
|
115
|
+
run: () => Promise<unknown | null>
|
|
116
|
+
): Promise<T | null> {
|
|
117
|
+
try {
|
|
118
|
+
const result = await run()
|
|
119
|
+
return result === null ? null : parse(result)
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
|
|
3
|
+
import { createEmptyState } from "./events"
|
|
4
|
+
|
|
5
|
+
describe("read models", () => {
|
|
6
|
+
test("include provider data in sidebar rows", () => {
|
|
7
|
+
const state = createEmptyState()
|
|
8
|
+
state.projectsById.set("project-1", {
|
|
9
|
+
id: "project-1",
|
|
10
|
+
localPath: "/tmp/project",
|
|
11
|
+
title: "Project",
|
|
12
|
+
createdAt: 1,
|
|
13
|
+
updatedAt: 1,
|
|
14
|
+
})
|
|
15
|
+
state.projectIdsByPath.set("/tmp/project", "project-1")
|
|
16
|
+
state.chatsById.set("chat-1", {
|
|
17
|
+
id: "chat-1",
|
|
18
|
+
projectId: "project-1",
|
|
19
|
+
title: "Chat",
|
|
20
|
+
createdAt: 1,
|
|
21
|
+
updatedAt: 1,
|
|
22
|
+
provider: "codex",
|
|
23
|
+
planMode: false,
|
|
24
|
+
sessionToken: "thread-1",
|
|
25
|
+
lastTurnOutcome: null,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const sidebar = deriveSidebarData(state, new Map())
|
|
29
|
+
expect(sidebar.projectGroups[0]?.chats[0]?.provider).toBe("codex")
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("includes available providers in chat snapshots", () => {
|
|
33
|
+
const state = createEmptyState()
|
|
34
|
+
state.projectsById.set("project-1", {
|
|
35
|
+
id: "project-1",
|
|
36
|
+
localPath: "/tmp/project",
|
|
37
|
+
title: "Project",
|
|
38
|
+
createdAt: 1,
|
|
39
|
+
updatedAt: 1,
|
|
40
|
+
})
|
|
41
|
+
state.projectIdsByPath.set("/tmp/project", "project-1")
|
|
42
|
+
state.chatsById.set("chat-1", {
|
|
43
|
+
id: "chat-1",
|
|
44
|
+
projectId: "project-1",
|
|
45
|
+
title: "Chat",
|
|
46
|
+
createdAt: 1,
|
|
47
|
+
updatedAt: 1,
|
|
48
|
+
provider: "claude",
|
|
49
|
+
planMode: true,
|
|
50
|
+
sessionToken: "session-1",
|
|
51
|
+
lastTurnOutcome: null,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const chat = deriveChatSnapshot(state, new Map(), "chat-1")
|
|
55
|
+
expect(chat?.runtime.provider).toBe("claude")
|
|
56
|
+
expect(chat?.availableProviders.length).toBeGreaterThan(1)
|
|
57
|
+
expect(chat?.availableProviders.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual([
|
|
58
|
+
"gpt-5.4",
|
|
59
|
+
"gpt-5.3-codex",
|
|
60
|
+
"gpt-5.3-codex-spark",
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("prefers saved project metadata over discovered entries for the same path", () => {
|
|
65
|
+
const state = createEmptyState()
|
|
66
|
+
state.projectsById.set("project-1", {
|
|
67
|
+
id: "project-1",
|
|
68
|
+
localPath: "/tmp/project",
|
|
69
|
+
title: "Saved Project",
|
|
70
|
+
createdAt: 1,
|
|
71
|
+
updatedAt: 50,
|
|
72
|
+
})
|
|
73
|
+
state.projectIdsByPath.set("/tmp/project", "project-1")
|
|
74
|
+
state.chatsById.set("chat-1", {
|
|
75
|
+
id: "chat-1",
|
|
76
|
+
projectId: "project-1",
|
|
77
|
+
title: "Chat",
|
|
78
|
+
createdAt: 1,
|
|
79
|
+
updatedAt: 75,
|
|
80
|
+
provider: "codex",
|
|
81
|
+
planMode: false,
|
|
82
|
+
sessionToken: null,
|
|
83
|
+
lastMessageAt: 100,
|
|
84
|
+
lastTurnOutcome: null,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const snapshot = deriveLocalProjectsSnapshot(state, [
|
|
88
|
+
{
|
|
89
|
+
localPath: "/tmp/project",
|
|
90
|
+
title: "Discovered Project",
|
|
91
|
+
modifiedAt: 10,
|
|
92
|
+
},
|
|
93
|
+
], "Local Machine")
|
|
94
|
+
|
|
95
|
+
expect(snapshot.projects).toEqual([
|
|
96
|
+
{
|
|
97
|
+
localPath: "/tmp/project",
|
|
98
|
+
title: "Saved Project",
|
|
99
|
+
source: "saved",
|
|
100
|
+
lastOpenedAt: 100,
|
|
101
|
+
chatCount: 1,
|
|
102
|
+
},
|
|
103
|
+
])
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
import type { ChatRecord, StoreState } from "./events"
|
|
11
11
|
import { cloneTranscriptEntries } from "./events"
|
|
12
12
|
import { resolveLocalPath } from "./paths"
|
|
13
|
+
import { SERVER_PROVIDERS } from "./provider-catalog"
|
|
13
14
|
|
|
14
15
|
export function deriveStatus(chat: ChatRecord, activeStatus?: KannaStatus): KannaStatus {
|
|
15
16
|
if (activeStatus) return activeStatus
|
|
@@ -36,6 +37,7 @@ export function deriveSidebarData(
|
|
|
36
37
|
title: chat.title,
|
|
37
38
|
status: deriveStatus(chat, activeStatuses.get(chat.id)),
|
|
38
39
|
localPath: project.localPath,
|
|
40
|
+
provider: chat.provider,
|
|
39
41
|
lastMessageAt: chat.lastMessageAt,
|
|
40
42
|
hasAutomation: false,
|
|
41
43
|
}))
|
|
@@ -109,12 +111,14 @@ export function deriveChatSnapshot(
|
|
|
109
111
|
localPath: project.localPath,
|
|
110
112
|
title: chat.title,
|
|
111
113
|
status: deriveStatus(chat, activeStatuses.get(chat.id)),
|
|
114
|
+
provider: chat.provider,
|
|
112
115
|
planMode: chat.planMode,
|
|
113
|
-
|
|
116
|
+
sessionToken: chat.sessionToken,
|
|
114
117
|
}
|
|
115
118
|
|
|
116
119
|
return {
|
|
117
120
|
runtime,
|
|
118
121
|
messages: cloneTranscriptEntries(state.messagesByChatId.get(chat.id) ?? []),
|
|
122
|
+
availableProviders: [...SERVER_PROVIDERS],
|
|
119
123
|
}
|
|
120
124
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -2,7 +2,7 @@ import path from "node:path"
|
|
|
2
2
|
import { APP_NAME } from "../shared/branding"
|
|
3
3
|
import { EventStore } from "./event-store"
|
|
4
4
|
import { AgentCoordinator } from "./agent"
|
|
5
|
-
import {
|
|
5
|
+
import { discoverProjects, type DiscoveredProject } from "./discovery"
|
|
6
6
|
import { getMachineDisplayName } from "./machine-name"
|
|
7
7
|
import { createWsRouter, type ClientState } from "./ws-router"
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
18
18
|
let discoveredProjects: DiscoveredProject[] = []
|
|
19
19
|
|
|
20
20
|
async function refreshDiscovery() {
|
|
21
|
-
discoveredProjects =
|
|
21
|
+
discoveredProjects = discoverProjects()
|
|
22
22
|
return discoveredProjects
|
|
23
23
|
}
|
|
24
24
|
|
package/src/shared/protocol.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChatSnapshot, LocalProjectsSnapshot, SidebarData } from "./types"
|
|
1
|
+
import type { AgentProvider, ChatSnapshot, LocalProjectsSnapshot, ModelOptions, SidebarData } from "./types"
|
|
2
2
|
|
|
3
3
|
export type SubscriptionTopic =
|
|
4
4
|
| { type: "sidebar" }
|
|
@@ -13,7 +13,17 @@ export type ClientCommand =
|
|
|
13
13
|
| { type: "chat.create"; projectId: string }
|
|
14
14
|
| { type: "chat.rename"; chatId: string; title: string }
|
|
15
15
|
| { type: "chat.delete"; chatId: string }
|
|
16
|
-
| {
|
|
16
|
+
| {
|
|
17
|
+
type: "chat.send"
|
|
18
|
+
chatId?: string
|
|
19
|
+
projectId?: string
|
|
20
|
+
provider?: AgentProvider
|
|
21
|
+
content: string
|
|
22
|
+
model?: string
|
|
23
|
+
modelOptions?: ModelOptions
|
|
24
|
+
effort?: string
|
|
25
|
+
planMode?: boolean
|
|
26
|
+
}
|
|
17
27
|
| { type: "chat.cancel"; chatId: string }
|
|
18
28
|
| { type: "chat.respondTool"; chatId: string; toolUseId: string; result: unknown }
|
|
19
29
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { hydrateToolResult, normalizeToolCall } from "./tools"
|
|
3
|
+
|
|
4
|
+
describe("normalizeToolCall", () => {
|
|
5
|
+
test("maps AskUserQuestion input to typed questions", () => {
|
|
6
|
+
const tool = normalizeToolCall({
|
|
7
|
+
toolName: "AskUserQuestion",
|
|
8
|
+
toolId: "tool-1",
|
|
9
|
+
input: {
|
|
10
|
+
questions: [
|
|
11
|
+
{
|
|
12
|
+
question: "Which runtime?",
|
|
13
|
+
header: "Runtime",
|
|
14
|
+
options: [{ label: "Codex", description: "Use Codex" }],
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
expect(tool.toolKind).toBe("ask_user_question")
|
|
21
|
+
if (tool.toolKind !== "ask_user_question") throw new Error("unexpected tool kind")
|
|
22
|
+
expect(tool.input.questions[0]?.question).toBe("Which runtime?")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("maps Bash snake_case input to camelCase", () => {
|
|
26
|
+
const tool = normalizeToolCall({
|
|
27
|
+
toolName: "Bash",
|
|
28
|
+
toolId: "tool-2",
|
|
29
|
+
input: {
|
|
30
|
+
command: "pwd",
|
|
31
|
+
timeout: 5000,
|
|
32
|
+
run_in_background: true,
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
expect(tool.toolKind).toBe("bash")
|
|
37
|
+
if (tool.toolKind !== "bash") throw new Error("unexpected tool kind")
|
|
38
|
+
expect(tool.input.timeoutMs).toBe(5000)
|
|
39
|
+
expect(tool.input.runInBackground).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("maps unknown MCP tools to mcp_generic", () => {
|
|
43
|
+
const tool = normalizeToolCall({
|
|
44
|
+
toolName: "mcp__sentry__search_issues",
|
|
45
|
+
toolId: "tool-3",
|
|
46
|
+
input: { query: "regression" },
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(tool.toolKind).toBe("mcp_generic")
|
|
50
|
+
if (tool.toolKind !== "mcp_generic") throw new Error("unexpected tool kind")
|
|
51
|
+
expect(tool.input.server).toBe("sentry")
|
|
52
|
+
expect(tool.input.tool).toBe("search_issues")
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("hydrateToolResult", () => {
|
|
57
|
+
test("hydrates AskUserQuestion answers", () => {
|
|
58
|
+
const tool = normalizeToolCall({
|
|
59
|
+
toolName: "AskUserQuestion",
|
|
60
|
+
toolId: "tool-1",
|
|
61
|
+
input: { questions: [] },
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const result = hydrateToolResult(tool, JSON.stringify({ answers: { runtime: "codex" } }))
|
|
65
|
+
expect(result).toEqual({ answers: { runtime: "codex" } })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("hydrates ExitPlanMode decisions", () => {
|
|
69
|
+
const tool = normalizeToolCall({
|
|
70
|
+
toolName: "ExitPlanMode",
|
|
71
|
+
toolId: "tool-2",
|
|
72
|
+
input: { plan: "Do the thing" },
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const result = hydrateToolResult(tool, { confirmed: true, clearContext: true })
|
|
76
|
+
expect(result).toEqual({ confirmed: true, clearContext: true, message: undefined })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("hydrates Read file text results", () => {
|
|
80
|
+
const tool = normalizeToolCall({
|
|
81
|
+
toolName: "Read",
|
|
82
|
+
toolId: "tool-3",
|
|
83
|
+
input: { file_path: "/tmp/example.ts" },
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(hydrateToolResult(tool, "line 1\nline 2")).toBe("line 1\nline 2")
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AskUserQuestionItem,
|
|
3
|
+
AskUserQuestionToolResult,
|
|
4
|
+
ExitPlanModeToolResult,
|
|
5
|
+
HydratedToolCall,
|
|
6
|
+
NormalizedToolCall,
|
|
7
|
+
ReadFileToolResult,
|
|
8
|
+
TodoItem,
|
|
9
|
+
} from "./types"
|
|
10
|
+
|
|
11
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
12
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
|
13
|
+
return value as Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeToolCall(args: {
|
|
17
|
+
toolName: string
|
|
18
|
+
toolId: string
|
|
19
|
+
input: Record<string, unknown>
|
|
20
|
+
}): NormalizedToolCall {
|
|
21
|
+
const { toolName, toolId, input } = args
|
|
22
|
+
|
|
23
|
+
switch (toolName) {
|
|
24
|
+
case "AskUserQuestion":
|
|
25
|
+
return {
|
|
26
|
+
kind: "tool",
|
|
27
|
+
toolKind: "ask_user_question",
|
|
28
|
+
toolName,
|
|
29
|
+
toolId,
|
|
30
|
+
input: {
|
|
31
|
+
questions: Array.isArray(input.questions) ? (input.questions as AskUserQuestionItem[]) : [],
|
|
32
|
+
},
|
|
33
|
+
rawInput: input,
|
|
34
|
+
}
|
|
35
|
+
case "ExitPlanMode":
|
|
36
|
+
return {
|
|
37
|
+
kind: "tool",
|
|
38
|
+
toolKind: "exit_plan_mode",
|
|
39
|
+
toolName,
|
|
40
|
+
toolId,
|
|
41
|
+
input: {
|
|
42
|
+
plan: typeof input.plan === "string" ? input.plan : undefined,
|
|
43
|
+
summary: typeof input.summary === "string" ? input.summary : undefined,
|
|
44
|
+
},
|
|
45
|
+
rawInput: input,
|
|
46
|
+
}
|
|
47
|
+
case "TodoWrite":
|
|
48
|
+
return {
|
|
49
|
+
kind: "tool",
|
|
50
|
+
toolKind: "todo_write",
|
|
51
|
+
toolName,
|
|
52
|
+
toolId,
|
|
53
|
+
input: {
|
|
54
|
+
todos: Array.isArray(input.todos) ? (input.todos as TodoItem[]) : [],
|
|
55
|
+
},
|
|
56
|
+
rawInput: input,
|
|
57
|
+
}
|
|
58
|
+
case "Skill":
|
|
59
|
+
return {
|
|
60
|
+
kind: "tool",
|
|
61
|
+
toolKind: "skill",
|
|
62
|
+
toolName,
|
|
63
|
+
toolId,
|
|
64
|
+
input: {
|
|
65
|
+
skill: typeof input.skill === "string" ? input.skill : "",
|
|
66
|
+
},
|
|
67
|
+
rawInput: input,
|
|
68
|
+
}
|
|
69
|
+
case "Glob":
|
|
70
|
+
return {
|
|
71
|
+
kind: "tool",
|
|
72
|
+
toolKind: "glob",
|
|
73
|
+
toolName,
|
|
74
|
+
toolId,
|
|
75
|
+
input: {
|
|
76
|
+
pattern: typeof input.pattern === "string" ? input.pattern : "",
|
|
77
|
+
},
|
|
78
|
+
rawInput: input,
|
|
79
|
+
}
|
|
80
|
+
case "Grep":
|
|
81
|
+
return {
|
|
82
|
+
kind: "tool",
|
|
83
|
+
toolKind: "grep",
|
|
84
|
+
toolName,
|
|
85
|
+
toolId,
|
|
86
|
+
input: {
|
|
87
|
+
pattern: typeof input.pattern === "string" ? input.pattern : "",
|
|
88
|
+
outputMode: typeof input.output_mode === "string" ? input.output_mode : undefined,
|
|
89
|
+
},
|
|
90
|
+
rawInput: input,
|
|
91
|
+
}
|
|
92
|
+
case "Bash":
|
|
93
|
+
return {
|
|
94
|
+
kind: "tool",
|
|
95
|
+
toolKind: "bash",
|
|
96
|
+
toolName,
|
|
97
|
+
toolId,
|
|
98
|
+
input: {
|
|
99
|
+
command: typeof input.command === "string" ? input.command : "",
|
|
100
|
+
description: typeof input.description === "string" ? input.description : undefined,
|
|
101
|
+
timeoutMs: typeof input.timeout === "number" ? input.timeout : undefined,
|
|
102
|
+
runInBackground: Boolean(input.run_in_background),
|
|
103
|
+
},
|
|
104
|
+
rawInput: input,
|
|
105
|
+
}
|
|
106
|
+
case "WebSearch":
|
|
107
|
+
return {
|
|
108
|
+
kind: "tool",
|
|
109
|
+
toolKind: "web_search",
|
|
110
|
+
toolName,
|
|
111
|
+
toolId,
|
|
112
|
+
input: {
|
|
113
|
+
query: typeof input.query === "string" ? input.query : "",
|
|
114
|
+
},
|
|
115
|
+
rawInput: input,
|
|
116
|
+
}
|
|
117
|
+
case "Read":
|
|
118
|
+
return {
|
|
119
|
+
kind: "tool",
|
|
120
|
+
toolKind: "read_file",
|
|
121
|
+
toolName,
|
|
122
|
+
toolId,
|
|
123
|
+
input: {
|
|
124
|
+
filePath: typeof input.file_path === "string" ? input.file_path : "",
|
|
125
|
+
},
|
|
126
|
+
rawInput: input,
|
|
127
|
+
}
|
|
128
|
+
case "Write":
|
|
129
|
+
return {
|
|
130
|
+
kind: "tool",
|
|
131
|
+
toolKind: "write_file",
|
|
132
|
+
toolName,
|
|
133
|
+
toolId,
|
|
134
|
+
input: {
|
|
135
|
+
filePath: typeof input.file_path === "string" ? input.file_path : "",
|
|
136
|
+
content: typeof input.content === "string" ? input.content : "",
|
|
137
|
+
},
|
|
138
|
+
rawInput: input,
|
|
139
|
+
}
|
|
140
|
+
case "Edit":
|
|
141
|
+
return {
|
|
142
|
+
kind: "tool",
|
|
143
|
+
toolKind: "edit_file",
|
|
144
|
+
toolName,
|
|
145
|
+
toolId,
|
|
146
|
+
input: {
|
|
147
|
+
filePath: typeof input.file_path === "string" ? input.file_path : "",
|
|
148
|
+
oldString: typeof input.old_string === "string" ? input.old_string : "",
|
|
149
|
+
newString: typeof input.new_string === "string" ? input.new_string : "",
|
|
150
|
+
},
|
|
151
|
+
rawInput: input,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const mcpMatch = toolName.match(/^mcp__(.+?)__(.+)$/)
|
|
156
|
+
if (mcpMatch) {
|
|
157
|
+
return {
|
|
158
|
+
kind: "tool",
|
|
159
|
+
toolKind: "mcp_generic",
|
|
160
|
+
toolName,
|
|
161
|
+
toolId,
|
|
162
|
+
input: {
|
|
163
|
+
server: mcpMatch[1],
|
|
164
|
+
tool: mcpMatch[2],
|
|
165
|
+
payload: input,
|
|
166
|
+
},
|
|
167
|
+
rawInput: input,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (typeof input.subagent_type === "string") {
|
|
172
|
+
return {
|
|
173
|
+
kind: "tool",
|
|
174
|
+
toolKind: "subagent_task",
|
|
175
|
+
toolName,
|
|
176
|
+
toolId,
|
|
177
|
+
input: {
|
|
178
|
+
subagentType: input.subagent_type,
|
|
179
|
+
},
|
|
180
|
+
rawInput: input,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
kind: "tool",
|
|
186
|
+
toolKind: "unknown_tool",
|
|
187
|
+
toolName,
|
|
188
|
+
toolId,
|
|
189
|
+
input: {
|
|
190
|
+
payload: input,
|
|
191
|
+
},
|
|
192
|
+
rawInput: input,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseJsonValue(value: unknown): unknown {
|
|
197
|
+
if (typeof value !== "string") return value
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(value)
|
|
200
|
+
} catch {
|
|
201
|
+
return value
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): HydratedToolCall["result"] {
|
|
206
|
+
const parsed = parseJsonValue(raw)
|
|
207
|
+
|
|
208
|
+
switch (tool.toolKind) {
|
|
209
|
+
case "ask_user_question": {
|
|
210
|
+
const record = asRecord(parsed)
|
|
211
|
+
const answers = asRecord(record?.answers) ?? (record ? record : {})
|
|
212
|
+
return { answers: Object.fromEntries(Object.entries(answers).map(([key, value]) => [key, String(value)])) } satisfies AskUserQuestionToolResult
|
|
213
|
+
}
|
|
214
|
+
case "exit_plan_mode": {
|
|
215
|
+
const record = asRecord(parsed)
|
|
216
|
+
return {
|
|
217
|
+
confirmed: typeof record?.confirmed === "boolean" ? record.confirmed : undefined,
|
|
218
|
+
clearContext: typeof record?.clearContext === "boolean" ? record.clearContext : undefined,
|
|
219
|
+
message: typeof record?.message === "string" ? record.message : undefined,
|
|
220
|
+
} satisfies ExitPlanModeToolResult
|
|
221
|
+
}
|
|
222
|
+
case "read_file":
|
|
223
|
+
if (typeof parsed === "string") {
|
|
224
|
+
return parsed
|
|
225
|
+
}
|
|
226
|
+
const record = asRecord(parsed)
|
|
227
|
+
return {
|
|
228
|
+
content: typeof record?.content === "string" ? record.content : JSON.stringify(parsed, null, 2),
|
|
229
|
+
} satisfies ReadFileToolResult
|
|
230
|
+
default:
|
|
231
|
+
return parsed
|
|
232
|
+
}
|
|
233
|
+
}
|