kanna-code 0.41.6 → 0.42.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/dist/client/assets/{index-BujIAIt0.css → index-COpRUlCZ.css} +1 -1
- package/dist/client/assets/{index-DqIqYlzq.js → index-DDpLGxoy.js} +205 -205
- package/dist/client/index.html +2 -2
- package/dist/export-viewer/assets/{index-Bw6GI0aH.css → index-DH7NuXKA.css} +1 -1
- package/dist/export-viewer/assets/{index--p-u0wfg.js → index-DoK2sc-h.js} +51 -51
- package/dist/export-viewer/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent.test.ts +1 -8
- package/src/server/agent.ts +41 -22
- package/src/server/app-settings.test.ts +48 -2
- package/src/server/app-settings.ts +43 -8
- package/src/server/async-queue.ts +45 -0
- package/src/server/codex-app-server.test.ts +70 -1
- package/src/server/codex-app-server.ts +4 -62
- package/src/server/cursor-cli.test.ts +219 -0
- package/src/server/cursor-cli.ts +376 -0
- package/src/server/diff-store.test.ts +47 -0
- package/src/server/diff-store.ts +2 -5
- package/src/server/paths.ts +64 -0
- package/src/server/provider-catalog.test.ts +31 -4
- package/src/server/provider-catalog.ts +28 -22
- package/src/server/read-models.test.ts +17 -0
- package/src/server/read-models.ts +2 -0
- package/src/server/transcript.ts +14 -0
- package/src/server/ws-router.test.ts +7 -0
- package/src/server/ws-router.ts +26 -3
- package/src/shared/assert.ts +7 -0
- package/src/shared/git-url.ts +54 -0
- package/src/shared/json.ts +14 -0
- package/src/shared/protocol.ts +1 -0
- package/src/shared/types.test.ts +50 -1
- package/src/shared/types.ts +150 -15
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { PassThrough } from "node:stream"
|
|
3
|
+
import { CursorCliManager, normalizeCursorUsage, parseCursorLine, type CursorChildProcess } from "./cursor-cli"
|
|
4
|
+
import type { HarnessEvent } from "./harness-types"
|
|
5
|
+
|
|
6
|
+
function transcriptEntries(events: HarnessEvent[]) {
|
|
7
|
+
return events.flatMap((event) => (event.type === "transcript" && event.entry ? [event.entry] : []))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function firstEntry(line: string, model = "composer-2.5") {
|
|
11
|
+
return transcriptEntries(parseCursorLine(line, model))[0]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("parseCursorLine", () => {
|
|
15
|
+
test("init emits a session token and a cursor system_init entry", () => {
|
|
16
|
+
const events = parseCursorLine(
|
|
17
|
+
`{"type":"system","subtype":"init","apiKeySource":"env","cwd":"/repo","session_id":"sess-1","model":"Composer 2.5 Fast"}`,
|
|
18
|
+
"composer-2.5-fast",
|
|
19
|
+
)
|
|
20
|
+
expect(events[0]).toEqual({ type: "session_token", sessionToken: "sess-1" })
|
|
21
|
+
// Uses the configured model id, not the display label ("Composer 2.5 Fast") from the event.
|
|
22
|
+
expect(transcriptEntries(events)[0]).toMatchObject({
|
|
23
|
+
kind: "system_init",
|
|
24
|
+
provider: "cursor",
|
|
25
|
+
model: "composer-2.5-fast",
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("assistant text becomes an assistant_text entry", () => {
|
|
30
|
+
const line = `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I found the bug"}]},"session_id":"s"}`
|
|
31
|
+
expect(firstEntry(line)).toMatchObject({ kind: "assistant_text", text: "I found the bug" })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("shell tool call maps onto Bash", () => {
|
|
35
|
+
const line = `{"type":"tool_call","subtype":"started","call_id":"c-1","tool_call":{"shellToolCall":{"args":{"command":"ls -la","description":"List files"}}},"session_id":"s"}`
|
|
36
|
+
expect(firstEntry(line)).toMatchObject({
|
|
37
|
+
kind: "tool_call",
|
|
38
|
+
tool: { toolKind: "bash", toolId: "c-1", input: { command: "ls -la" } },
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("read tool call maps onto Read with the file path", () => {
|
|
43
|
+
const line = `{"type":"tool_call","subtype":"started","call_id":"c-2","tool_call":{"readToolCall":{"args":{"path":"/repo/note.txt"}}},"session_id":"s"}`
|
|
44
|
+
expect(firstEntry(line)).toMatchObject({
|
|
45
|
+
kind: "tool_call",
|
|
46
|
+
tool: { toolKind: "read_file", input: { filePath: "/repo/note.txt" } },
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("edit tool call maps Cursor's path/streamContent onto Write", () => {
|
|
51
|
+
const line = `{"type":"tool_call","subtype":"started","call_id":"c-3","tool_call":{"editToolCall":{"args":{"path":"/repo/calc.py","streamContent":"def add(a, b):\\n return a + b\\n"}}},"session_id":"s"}`
|
|
52
|
+
expect(firstEntry(line)).toMatchObject({
|
|
53
|
+
kind: "tool_call",
|
|
54
|
+
tool: { toolKind: "write_file", input: { filePath: "/repo/calc.py", content: expect.stringContaining("def add") } },
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("glob / grep / updateTodos map onto Glob / Grep / TodoWrite", () => {
|
|
59
|
+
expect(firstEntry(`{"type":"tool_call","subtype":"started","call_id":"g-1","tool_call":{"globToolCall":{"args":{"targetDirectory":"/repo","globPattern":"**/*.py"}}},"session_id":"s"}`))
|
|
60
|
+
.toMatchObject({ kind: "tool_call", tool: { toolKind: "glob", input: { pattern: "**/*.py" } } })
|
|
61
|
+
|
|
62
|
+
expect(firstEntry(`{"type":"tool_call","subtype":"started","call_id":"g-2","tool_call":{"grepToolCall":{"args":{"pattern":"def add","path":"/repo"}}},"session_id":"s"}`))
|
|
63
|
+
.toMatchObject({ kind: "tool_call", tool: { toolKind: "grep", input: { pattern: "def add" } } })
|
|
64
|
+
|
|
65
|
+
expect(firstEntry(`{"type":"tool_call","subtype":"started","call_id":"t-1","tool_call":{"updateTodosToolCall":{"args":{"todos":[{"content":"step","status":"pending"}]}}},"session_id":"s"}`))
|
|
66
|
+
.toMatchObject({ kind: "tool_call", tool: { toolKind: "todo_write", input: { todos: [{ content: "step", status: "pending" }] } } })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("an unmapped tool falls through to unknown_tool rather than being dropped", () => {
|
|
70
|
+
const line = `{"type":"tool_call","subtype":"started","call_id":"u-1","tool_call":{"mysteryToolCall":{"args":{"foo":"bar"}}},"session_id":"s"}`
|
|
71
|
+
expect(firstEntry(line)).toMatchObject({ kind: "tool_call", tool: { toolKind: "unknown_tool" } })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("completed tool call becomes a tool_result keyed by call id", () => {
|
|
75
|
+
const line = `{"type":"tool_call","subtype":"completed","call_id":"c-2","tool_call":{"readToolCall":{"args":{"path":"/repo/note.txt"},"result":{"success":{"content":"hello world\\n","isEmpty":false}}}},"session_id":"s"}`
|
|
76
|
+
expect(firstEntry(line)).toMatchObject({
|
|
77
|
+
kind: "tool_result",
|
|
78
|
+
toolId: "c-2",
|
|
79
|
+
isError: false,
|
|
80
|
+
content: { content: "hello world\n", isEmpty: false },
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("a failed tool call is flagged as an error result", () => {
|
|
85
|
+
const line = `{"type":"tool_call","subtype":"completed","call_id":"c-9","tool_call":{"shellToolCall":{"args":{"command":"nope"},"result":{"error":{"message":"boom"}}}},"session_id":"s"}`
|
|
86
|
+
expect(firstEntry(line)).toMatchObject({ kind: "tool_result", toolId: "c-9", isError: true })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("a completed tool with no structured result is not a false-positive error", () => {
|
|
90
|
+
const line = `{"type":"tool_call","subtype":"completed","call_id":"c-7","tool_call":{"shellToolCall":{"args":{"command":"ls"}}},"session_id":"s"}`
|
|
91
|
+
expect(firstEntry(line)).toMatchObject({ kind: "tool_result", toolId: "c-7", isError: false })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("result emits a context-window update followed by a success result", () => {
|
|
95
|
+
const line = `{"type":"result","subtype":"success","duration_ms":15609,"is_error":false,"result":"Done","session_id":"s","usage":{"inputTokens":17640,"outputTokens":138,"cacheReadTokens":27968,"cacheWriteTokens":0}}`
|
|
96
|
+
const entries = transcriptEntries(parseCursorLine(line, "composer-2.5"))
|
|
97
|
+
expect(entries.map((entry) => entry.kind)).toEqual(["context_window_updated", "result"])
|
|
98
|
+
expect(entries[1]).toMatchObject({ kind: "result", isError: false, subtype: "success", result: "Done", durationMs: 15609 })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("error result is surfaced as an error result entry", () => {
|
|
102
|
+
const line = `{"type":"result","subtype":"error","is_error":true,"result":"boom","session_id":"s"}`
|
|
103
|
+
const entries = transcriptEntries(parseCursorLine(line, "composer-2.5"))
|
|
104
|
+
expect(entries).toHaveLength(1)
|
|
105
|
+
expect(entries[0]).toMatchObject({ kind: "result", isError: true, subtype: "error", result: "boom" })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("prompt echo, reasoning, and malformed lines produce no entries", () => {
|
|
109
|
+
expect(parseCursorLine(`{"type":"user","message":{"content":[{"type":"text","text":"hi"}]}}`, "composer-2.5")).toEqual([])
|
|
110
|
+
expect(parseCursorLine(`{"type":"thinking","subtype":"delta"}`, "composer-2.5")).toEqual([])
|
|
111
|
+
expect(parseCursorLine("not json", "composer-2.5")).toEqual([])
|
|
112
|
+
expect(parseCursorLine("", "composer-2.5")).toEqual([])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe("normalizeCursorUsage", () => {
|
|
117
|
+
test("sums direct + cache tokens into the context window snapshot", () => {
|
|
118
|
+
const usage = normalizeCursorUsage({ inputTokens: 17640, outputTokens: 138, cacheReadTokens: 27968, cacheWriteTokens: 0 })
|
|
119
|
+
expect(usage).toMatchObject({
|
|
120
|
+
inputTokens: 17640 + 27968,
|
|
121
|
+
cachedInputTokens: 27968,
|
|
122
|
+
outputTokens: 138,
|
|
123
|
+
usedTokens: 17640 + 27968 + 138,
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("returns null when there is no usage", () => {
|
|
128
|
+
expect(normalizeCursorUsage(undefined)).toBeNull()
|
|
129
|
+
expect(normalizeCursorUsage({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Exercises the injectable `spawnProcess` seam: argv/stdin/resume wiring, stdout
|
|
134
|
+
// stream -> events, and the "process ended without a result" error synthesis.
|
|
135
|
+
function makeFakeChild() {
|
|
136
|
+
const stdin = new PassThrough()
|
|
137
|
+
const stdout = new PassThrough()
|
|
138
|
+
const stderr = new PassThrough()
|
|
139
|
+
let onClose: ((code: number | null) => void) | undefined
|
|
140
|
+
const child = {
|
|
141
|
+
stdin,
|
|
142
|
+
stdout,
|
|
143
|
+
stderr,
|
|
144
|
+
kill: () => true,
|
|
145
|
+
once(event: "close" | "error", listener: (arg: never) => void) {
|
|
146
|
+
if (event === "close") onClose = listener as unknown as (code: number | null) => void
|
|
147
|
+
return child
|
|
148
|
+
},
|
|
149
|
+
} as unknown as CursorChildProcess
|
|
150
|
+
return { child, stdin, stdout, stderr, close: (code: number | null) => onClose?.(code) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("CursorCliManager.startTurn", () => {
|
|
154
|
+
test("passes --force/--model/--resume and writes the prompt to stdin", async () => {
|
|
155
|
+
const fake = makeFakeChild()
|
|
156
|
+
let captured: { cwd: string; argv: string[] } | undefined
|
|
157
|
+
const manager = new CursorCliManager({ spawnProcess: (args) => { captured = args; return fake.child } })
|
|
158
|
+
|
|
159
|
+
const stdinText = new Promise<string>((resolve) => {
|
|
160
|
+
let data = ""
|
|
161
|
+
fake.stdin.on("data", (chunk) => { data += chunk.toString() })
|
|
162
|
+
fake.stdin.on("end", () => resolve(data))
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const turn = await manager.startTurn({ cwd: "/repo", content: "fix the bug", model: "composer-2.5-fast", sessionToken: "sess-9" })
|
|
166
|
+
|
|
167
|
+
expect(turn.provider).toBe("cursor")
|
|
168
|
+
expect(captured?.cwd).toBe("/repo")
|
|
169
|
+
expect(captured?.argv).toEqual([
|
|
170
|
+
"-p", "--output-format", "stream-json", "--force", "--model", "composer-2.5-fast", "--resume", "sess-9",
|
|
171
|
+
])
|
|
172
|
+
expect(await stdinText).toBe("fix the bug")
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("omits --resume when there is no session token", async () => {
|
|
176
|
+
const fake = makeFakeChild()
|
|
177
|
+
let captured: { cwd: string; argv: string[] } | undefined
|
|
178
|
+
const manager = new CursorCliManager({ spawnProcess: (args) => { captured = args; return fake.child } })
|
|
179
|
+
await manager.startTurn({ cwd: "/repo", content: "hi", model: "composer-2.5", sessionToken: null })
|
|
180
|
+
expect(captured?.argv).not.toContain("--resume")
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("parses stdout NDJSON into events without synthesizing an error", async () => {
|
|
184
|
+
const fake = makeFakeChild()
|
|
185
|
+
const manager = new CursorCliManager({ spawnProcess: () => fake.child })
|
|
186
|
+
const turn = await manager.startTurn({ cwd: "/repo", content: "hi", model: "composer-2.5", sessionToken: null })
|
|
187
|
+
const iter = turn.stream[Symbol.asyncIterator]()
|
|
188
|
+
|
|
189
|
+
fake.stdout.write(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]},"session_id":"s"}\n`)
|
|
190
|
+
fake.stdout.write(`{"type":"result","subtype":"success","is_error":false,"result":"done","session_id":"s"}\n`)
|
|
191
|
+
|
|
192
|
+
// The consumer awaits each event as it is pushed, so this is deterministic without timers.
|
|
193
|
+
expect((await iter.next()).value).toMatchObject({ type: "transcript", entry: { kind: "assistant_text", text: "hi there" } })
|
|
194
|
+
expect((await iter.next()).value).toMatchObject({ type: "transcript", entry: { kind: "result", isError: false } })
|
|
195
|
+
|
|
196
|
+
fake.stdout.end()
|
|
197
|
+
fake.close(0)
|
|
198
|
+
expect((await iter.next()).done).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test("synthesizes an error result (with stderr) when the process ends without one", async () => {
|
|
202
|
+
const fake = makeFakeChild()
|
|
203
|
+
const manager = new CursorCliManager({ spawnProcess: () => fake.child })
|
|
204
|
+
const turn = await manager.startTurn({ cwd: "/repo", content: "hi", model: "bad-model", sessionToken: null })
|
|
205
|
+
const iter = turn.stream[Symbol.asyncIterator]()
|
|
206
|
+
|
|
207
|
+
// Awaiting stderr's "end" guarantees the manager's "data" handler ran first.
|
|
208
|
+
fake.stderr.end("Cannot use this model: bad-model")
|
|
209
|
+
await new Promise<void>((resolve) => fake.stderr.once("end", resolve))
|
|
210
|
+
fake.stdout.end()
|
|
211
|
+
fake.close(1)
|
|
212
|
+
|
|
213
|
+
expect((await iter.next()).value).toMatchObject({
|
|
214
|
+
type: "transcript",
|
|
215
|
+
entry: { kind: "result", isError: true, subtype: "error", result: expect.stringContaining("Cannot use this model") },
|
|
216
|
+
})
|
|
217
|
+
expect((await iter.next()).done).toBe(true)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { createInterface } from "node:readline"
|
|
4
|
+
import type { Readable, Writable } from "node:stream"
|
|
5
|
+
import type { ContextWindowUsageSnapshot } from "../shared/types"
|
|
6
|
+
import { asNumber, asRecord, asString } from "../shared/json"
|
|
7
|
+
import { normalizeToolCall } from "../shared/tools"
|
|
8
|
+
import type { HarnessEvent, HarnessTurn } from "./harness-types"
|
|
9
|
+
import { AsyncQueue } from "./async-queue"
|
|
10
|
+
import { timestamped } from "./transcript"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Adapter for the Cursor CLI (`cursor-agent` binary).
|
|
14
|
+
*
|
|
15
|
+
* Unlike Claude (SDK) and Codex (persistent JSON-RPC `app-server`), Cursor runs
|
|
16
|
+
* one headless process per turn:
|
|
17
|
+
*
|
|
18
|
+
* cursor-agent -p --output-format stream-json --force --model <id> [--resume <session>]
|
|
19
|
+
*
|
|
20
|
+
* with the prompt written to stdin. It emits NDJSON on stdout. `--force` is required
|
|
21
|
+
* in headless mode, otherwise the process blocks on a "Workspace Trust" prompt.
|
|
22
|
+
* Auth is via the CURSOR_API_KEY environment variable (inherited from the parent).
|
|
23
|
+
*
|
|
24
|
+
* Stream event types (one JSON object per line):
|
|
25
|
+
* - { type: "system", subtype: "init", session_id, model, cwd, apiKeySource }
|
|
26
|
+
* - { type: "user", ... } (prompt echo — ignored)
|
|
27
|
+
* - { type: "assistant", message: { content: [{ type: "text", text }] } }
|
|
28
|
+
* - { type: "thinking", subtype: "delta"|"completed" } (reasoning — ignored)
|
|
29
|
+
* - { type: "tool_call", subtype: "started"|"completed", call_id, tool_call: { <name>ToolCall: {...} } }
|
|
30
|
+
* - { type: "result", subtype: "success", is_error, duration_ms, result, session_id, usage }
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// Minimal child-process surface so tests can inject a fake without rebuilding ChildProcess.
|
|
34
|
+
export interface CursorChildProcess {
|
|
35
|
+
readonly stdin: Writable | null
|
|
36
|
+
readonly stdout: Readable | null
|
|
37
|
+
readonly stderr: Readable | null
|
|
38
|
+
kill(signal?: NodeJS.Signals): boolean
|
|
39
|
+
once(event: "close", listener: (code: number | null) => void): unknown
|
|
40
|
+
once(event: "error", listener: (err: Error) => void): unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SpawnCursorAgent = (args: { cwd: string; argv: string[] }) => CursorChildProcess
|
|
44
|
+
|
|
45
|
+
export interface StartCursorTurnArgs {
|
|
46
|
+
cwd: string
|
|
47
|
+
content: string
|
|
48
|
+
/** Concrete model id to spawn, e.g. "composer-2.5" or "composer-2.5-fast". */
|
|
49
|
+
model: string
|
|
50
|
+
/** Previous Cursor session id to resume, if any. */
|
|
51
|
+
sessionToken: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Map Cursor's token usage into Kanna's context-window snapshot. Mirrors
|
|
56
|
+
* `normalizeClaudeUsageSnapshot` in agent.ts. Cursor does not report a max
|
|
57
|
+
* context window, so `maxTokens` is left undefined.
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeCursorUsage(value: unknown): ContextWindowUsageSnapshot | null {
|
|
60
|
+
const usage = asRecord(value)
|
|
61
|
+
if (!usage) return null
|
|
62
|
+
|
|
63
|
+
const directInputTokens = asNumber(usage.inputTokens) ?? 0
|
|
64
|
+
const cacheReadTokens = asNumber(usage.cacheReadTokens) ?? 0
|
|
65
|
+
const cacheWriteTokens = asNumber(usage.cacheWriteTokens) ?? 0
|
|
66
|
+
const outputTokens = asNumber(usage.outputTokens) ?? 0
|
|
67
|
+
|
|
68
|
+
const inputTokens = directInputTokens + cacheReadTokens + cacheWriteTokens
|
|
69
|
+
const usedTokens = inputTokens + outputTokens
|
|
70
|
+
if (usedTokens <= 0) return null
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
usedTokens,
|
|
74
|
+
inputTokens,
|
|
75
|
+
...(cacheReadTokens > 0 ? { cachedInputTokens: cacheReadTokens } : {}),
|
|
76
|
+
...(outputTokens > 0 ? { outputTokens } : {}),
|
|
77
|
+
lastUsedTokens: usedTokens,
|
|
78
|
+
lastInputTokens: inputTokens,
|
|
79
|
+
...(cacheReadTokens > 0 ? { lastCachedInputTokens: cacheReadTokens } : {}),
|
|
80
|
+
...(outputTokens > 0 ? { lastOutputTokens: outputTokens } : {}),
|
|
81
|
+
compactsAutomatically: false,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Translate a Cursor tool call into the Claude-style tool name + snake_case
|
|
87
|
+
* argument keys that `normalizeToolCall` understands, so tools render natively
|
|
88
|
+
* in the UI. Unknown tools fall through to `unknown_tool`.
|
|
89
|
+
*/
|
|
90
|
+
function translateCursorTool(
|
|
91
|
+
rawName: string,
|
|
92
|
+
args: Record<string, unknown>
|
|
93
|
+
): { toolName: string; input: Record<string, unknown> } {
|
|
94
|
+
// Tool keys and argument names are taken from observed `cursor-agent` stream
|
|
95
|
+
// output. Anything unmapped falls through to `unknown_tool`, which still renders.
|
|
96
|
+
switch (rawName.toLowerCase().replace(/[^a-z]/g, "")) {
|
|
97
|
+
case "shell":
|
|
98
|
+
return { toolName: "Bash", input: { command: args.command ?? "", description: args.description } }
|
|
99
|
+
case "read":
|
|
100
|
+
return { toolName: "Read", input: { file_path: args.path ?? "" } }
|
|
101
|
+
case "edit":
|
|
102
|
+
// Cursor's edit emits the full new file content (`streamContent`) rather than
|
|
103
|
+
// an old/new diff, so it maps cleanly onto Write.
|
|
104
|
+
return { toolName: "Write", input: { file_path: args.path ?? "", content: args.streamContent ?? "" } }
|
|
105
|
+
case "glob":
|
|
106
|
+
return { toolName: "Glob", input: { pattern: args.globPattern ?? "" } }
|
|
107
|
+
case "grep":
|
|
108
|
+
return { toolName: "Grep", input: { pattern: args.pattern ?? "" } }
|
|
109
|
+
case "updatetodos":
|
|
110
|
+
return { toolName: "TodoWrite", input: { todos: Array.isArray(args.todos) ? args.todos : [] } }
|
|
111
|
+
default:
|
|
112
|
+
return { toolName: rawName, input: args }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Cursor nests tool calls under keys like "shellToolCall", "readToolCall".
|
|
118
|
+
* Returns the raw tool name, its args, and the completed result (if present).
|
|
119
|
+
*/
|
|
120
|
+
function extractCursorTool(toolCall: unknown): {
|
|
121
|
+
rawName: string
|
|
122
|
+
args: Record<string, unknown>
|
|
123
|
+
result?: unknown
|
|
124
|
+
} {
|
|
125
|
+
const obj = asRecord(toolCall)
|
|
126
|
+
if (obj) {
|
|
127
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
128
|
+
if (k.endsWith("ToolCall")) {
|
|
129
|
+
const inner = asRecord(v)
|
|
130
|
+
return {
|
|
131
|
+
rawName: k.slice(0, -"ToolCall".length),
|
|
132
|
+
args: asRecord(inner?.args) ?? {},
|
|
133
|
+
result: inner?.result,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Fallback: { name, args }
|
|
138
|
+
const name = asString(obj.name)
|
|
139
|
+
if (name) {
|
|
140
|
+
return { rawName: name, args: asRecord(obj.args) ?? {}, result: obj.result }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { rawName: "unknown", args: {} }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractAssistantText(message: unknown): string {
|
|
147
|
+
const content = asRecord(message)?.content
|
|
148
|
+
if (!Array.isArray(content)) return ""
|
|
149
|
+
return content
|
|
150
|
+
.filter((item): item is Record<string, unknown> => asRecord(item)?.type === "text")
|
|
151
|
+
.map((item) => asString(item.text) ?? "")
|
|
152
|
+
.join("")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse a single NDJSON line from `cursor-agent` into Kanna harness events.
|
|
157
|
+
* Pure and side-effect free so it can be unit tested against captured fixtures.
|
|
158
|
+
*/
|
|
159
|
+
export function parseCursorLine(line: string, configuredModel: string): HarnessEvent[] {
|
|
160
|
+
const trimmed = line.trim()
|
|
161
|
+
if (!trimmed) return []
|
|
162
|
+
|
|
163
|
+
let value: Record<string, unknown> | null
|
|
164
|
+
try {
|
|
165
|
+
value = asRecord(JSON.parse(trimmed))
|
|
166
|
+
} catch {
|
|
167
|
+
return []
|
|
168
|
+
}
|
|
169
|
+
if (!value) return []
|
|
170
|
+
|
|
171
|
+
const type = asString(value.type)
|
|
172
|
+
const debugRaw = trimmed
|
|
173
|
+
|
|
174
|
+
switch (type) {
|
|
175
|
+
case "system": {
|
|
176
|
+
if (asString(value.subtype) !== "init") return []
|
|
177
|
+
const events: HarnessEvent[] = []
|
|
178
|
+
const sessionId = asString(value.session_id)
|
|
179
|
+
if (sessionId) events.push({ type: "session_token", sessionToken: sessionId })
|
|
180
|
+
events.push({
|
|
181
|
+
type: "transcript",
|
|
182
|
+
entry: timestamped({
|
|
183
|
+
kind: "system_init",
|
|
184
|
+
provider: "cursor",
|
|
185
|
+
model: configuredModel,
|
|
186
|
+
tools: ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "TodoWrite"],
|
|
187
|
+
agents: [],
|
|
188
|
+
slashCommands: [],
|
|
189
|
+
mcpServers: [],
|
|
190
|
+
debugRaw,
|
|
191
|
+
}),
|
|
192
|
+
})
|
|
193
|
+
return events
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case "assistant": {
|
|
197
|
+
const text = extractAssistantText(value.message)
|
|
198
|
+
if (!text) return []
|
|
199
|
+
return [{ type: "transcript", entry: timestamped({ kind: "assistant_text", text, debugRaw }) }]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case "tool_call": {
|
|
203
|
+
const subtype = asString(value.subtype)
|
|
204
|
+
const callId = asString(value.call_id) ?? randomUUID()
|
|
205
|
+
const { rawName, args, result } = extractCursorTool(value.tool_call)
|
|
206
|
+
|
|
207
|
+
if (subtype === "started") {
|
|
208
|
+
const { toolName, input } = translateCursorTool(rawName, args)
|
|
209
|
+
return [
|
|
210
|
+
{
|
|
211
|
+
type: "transcript",
|
|
212
|
+
entry: timestamped({
|
|
213
|
+
kind: "tool_call",
|
|
214
|
+
tool: normalizeToolCall({ toolName, toolId: callId, input }),
|
|
215
|
+
debugRaw,
|
|
216
|
+
}),
|
|
217
|
+
},
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (subtype === "completed") {
|
|
222
|
+
// Only flag an error when Cursor explicitly reports one. A missing/non-object
|
|
223
|
+
// result (e.g. a bare string) is treated as success rather than a false error.
|
|
224
|
+
const resultRecord = asRecord(result)
|
|
225
|
+
const isError = Boolean(resultRecord && ("error" in resultRecord || "failure" in resultRecord))
|
|
226
|
+
const content = resultRecord?.success ?? resultRecord?.error ?? resultRecord?.failure ?? result
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
type: "transcript",
|
|
230
|
+
entry: timestamped({
|
|
231
|
+
kind: "tool_result",
|
|
232
|
+
toolId: callId,
|
|
233
|
+
content,
|
|
234
|
+
isError,
|
|
235
|
+
debugRaw,
|
|
236
|
+
}),
|
|
237
|
+
},
|
|
238
|
+
]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return []
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case "result": {
|
|
245
|
+
const events: HarnessEvent[] = []
|
|
246
|
+
const usage = normalizeCursorUsage(value.usage)
|
|
247
|
+
if (usage) {
|
|
248
|
+
events.push({
|
|
249
|
+
type: "transcript",
|
|
250
|
+
entry: timestamped({ kind: "context_window_updated", usage }),
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
const isError = Boolean(value.is_error)
|
|
254
|
+
events.push({
|
|
255
|
+
type: "transcript",
|
|
256
|
+
entry: timestamped({
|
|
257
|
+
kind: "result",
|
|
258
|
+
subtype: isError ? "error" : "success",
|
|
259
|
+
isError,
|
|
260
|
+
durationMs: asNumber(value.duration_ms) ?? 0,
|
|
261
|
+
result: asString(value.result) ?? "",
|
|
262
|
+
debugRaw,
|
|
263
|
+
}),
|
|
264
|
+
})
|
|
265
|
+
return events
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// "user" (prompt echo), "thinking" (reasoning), and unknown types are dropped —
|
|
269
|
+
// Kanna already records the user prompt and has no reasoning transcript kind.
|
|
270
|
+
default:
|
|
271
|
+
return []
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export class CursorCliManager {
|
|
276
|
+
private readonly spawnProcess: SpawnCursorAgent
|
|
277
|
+
|
|
278
|
+
constructor(args: { spawnProcess?: SpawnCursorAgent } = {}) {
|
|
279
|
+
this.spawnProcess =
|
|
280
|
+
args.spawnProcess ??
|
|
281
|
+
(({ cwd, argv }) =>
|
|
282
|
+
spawn("cursor-agent", argv, {
|
|
283
|
+
cwd,
|
|
284
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
285
|
+
env: process.env,
|
|
286
|
+
}) as unknown as CursorChildProcess)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async startTurn(args: StartCursorTurnArgs): Promise<HarnessTurn> {
|
|
290
|
+
const argv = [
|
|
291
|
+
"-p",
|
|
292
|
+
"--output-format",
|
|
293
|
+
"stream-json",
|
|
294
|
+
"--force",
|
|
295
|
+
"--model",
|
|
296
|
+
args.model,
|
|
297
|
+
]
|
|
298
|
+
if (args.sessionToken) {
|
|
299
|
+
argv.push("--resume", args.sessionToken)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const child = this.spawnProcess({ cwd: args.cwd, argv })
|
|
303
|
+
const queue = new AsyncQueue<HarnessEvent>()
|
|
304
|
+
|
|
305
|
+
let sawResult = false
|
|
306
|
+
let finished = false
|
|
307
|
+
let stderr = ""
|
|
308
|
+
|
|
309
|
+
const finalize = (code: number | null) => {
|
|
310
|
+
if (finished) return
|
|
311
|
+
finished = true
|
|
312
|
+
if (!sawResult) {
|
|
313
|
+
const detail = stderr.trim() || `cursor-agent exited with code ${code ?? "unknown"}`
|
|
314
|
+
queue.push({
|
|
315
|
+
type: "transcript",
|
|
316
|
+
entry: timestamped({
|
|
317
|
+
kind: "result",
|
|
318
|
+
subtype: "error",
|
|
319
|
+
isError: true,
|
|
320
|
+
durationMs: 0,
|
|
321
|
+
result: detail,
|
|
322
|
+
}),
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
queue.finish()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (child.stdout) {
|
|
329
|
+
const rl = createInterface({ input: child.stdout })
|
|
330
|
+
rl.on("line", (line) => {
|
|
331
|
+
for (const event of parseCursorLine(line, args.model)) {
|
|
332
|
+
if (event.type === "transcript" && event.entry?.kind === "result") {
|
|
333
|
+
sawResult = true
|
|
334
|
+
}
|
|
335
|
+
queue.push(event)
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (child.stderr) {
|
|
341
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
342
|
+
stderr += chunk.toString()
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
child.once("error", (err) => {
|
|
347
|
+
stderr += `\n${err.message}`
|
|
348
|
+
finalize(null)
|
|
349
|
+
})
|
|
350
|
+
child.once("close", (code) => finalize(code))
|
|
351
|
+
|
|
352
|
+
// Send the prompt over stdin and close it so the agent starts.
|
|
353
|
+
if (child.stdin) {
|
|
354
|
+
child.stdin.end(args.content)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
provider: "cursor",
|
|
359
|
+
stream: queue,
|
|
360
|
+
interrupt: async () => {
|
|
361
|
+
try {
|
|
362
|
+
child.kill("SIGINT")
|
|
363
|
+
} catch {
|
|
364
|
+
// process already gone
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
close: () => {
|
|
368
|
+
try {
|
|
369
|
+
child.kill()
|
|
370
|
+
} catch {
|
|
371
|
+
// process already gone
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -633,6 +633,53 @@ describe("DiffStore", () => {
|
|
|
633
633
|
expect((await run(["git", "branch", "--show-current"], repoRoot)).trim()).toBe("feature/new")
|
|
634
634
|
})
|
|
635
635
|
|
|
636
|
+
test("syncBranch pull rebases divergent local commits onto the upstream branch", async () => {
|
|
637
|
+
const repoRoot = await createRepo()
|
|
638
|
+
const remoteRoot = await createBareRemote()
|
|
639
|
+
const remoteWorktree = await mkdtemp(path.join(tmpdir(), "kanna-diff-remote-worktree-"))
|
|
640
|
+
tempDirs.push(repoRoot, remoteRoot, remoteWorktree)
|
|
641
|
+
|
|
642
|
+
await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
|
|
643
|
+
await run(["git", "add", "."], repoRoot)
|
|
644
|
+
await run(["git", "commit", "-m", "base"], repoRoot)
|
|
645
|
+
await run(["git", "branch", "-M", "main"], repoRoot)
|
|
646
|
+
await run(["git", "remote", "add", "origin", remoteRoot], repoRoot)
|
|
647
|
+
await run(["git", "push", "-u", "origin", "main"], repoRoot)
|
|
648
|
+
|
|
649
|
+
await run(["git", "clone", "-b", "main", remoteRoot, remoteWorktree], tmpdir())
|
|
650
|
+
await run(["git", "config", "user.email", "kanna@example.com"], remoteWorktree)
|
|
651
|
+
await run(["git", "config", "user.name", "Kanna"], remoteWorktree)
|
|
652
|
+
await writeFile(path.join(remoteWorktree, "remote.txt"), "remote\n", "utf8")
|
|
653
|
+
await run(["git", "add", "."], remoteWorktree)
|
|
654
|
+
await run(["git", "commit", "-m", "remote change"], remoteWorktree)
|
|
655
|
+
await run(["git", "push"], remoteWorktree)
|
|
656
|
+
|
|
657
|
+
await writeFile(path.join(repoRoot, "local.txt"), "local\n", "utf8")
|
|
658
|
+
await run(["git", "add", "."], repoRoot)
|
|
659
|
+
await run(["git", "commit", "-m", "local change"], repoRoot)
|
|
660
|
+
|
|
661
|
+
const store = new DiffStore(repoRoot)
|
|
662
|
+
await store.initialize()
|
|
663
|
+
const result = await store.syncBranch({
|
|
664
|
+
projectId: "project-1",
|
|
665
|
+
projectPath: repoRoot,
|
|
666
|
+
action: "pull",
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
expect(result).toMatchObject({
|
|
670
|
+
ok: true,
|
|
671
|
+
action: "pull",
|
|
672
|
+
branchName: "main",
|
|
673
|
+
aheadCount: 1,
|
|
674
|
+
behindCount: 0,
|
|
675
|
+
snapshotChanged: true,
|
|
676
|
+
})
|
|
677
|
+
expect((await run(["git", "log", "--format=%s", "-2"], repoRoot)).trim().split("\n")).toEqual([
|
|
678
|
+
"local change",
|
|
679
|
+
"remote change",
|
|
680
|
+
])
|
|
681
|
+
})
|
|
682
|
+
|
|
636
683
|
test("previewMergeBranch reports up-to-date and mergeable states", async () => {
|
|
637
684
|
const repoRoot = await createRepo()
|
|
638
685
|
tempDirs.push(repoRoot)
|
package/src/server/diff-store.ts
CHANGED
|
@@ -1903,7 +1903,7 @@ export class DiffStore {
|
|
|
1903
1903
|
}
|
|
1904
1904
|
|
|
1905
1905
|
const syncResult = args.action === "pull"
|
|
1906
|
-
? await runGit(["pull", "--
|
|
1906
|
+
? await runGit(["pull", "--rebase", "--autostash"], repo.repoRoot)
|
|
1907
1907
|
: await runGit(["fetch", "--all", "--prune"], repo.repoRoot)
|
|
1908
1908
|
|
|
1909
1909
|
if (syncResult.exitCode !== 0) {
|
|
@@ -1912,10 +1912,7 @@ export class DiffStore {
|
|
|
1912
1912
|
let title = args.action === "pull" ? "Pull failed" : "Fetch failed"
|
|
1913
1913
|
let message = summarizeGitFailure(detail, args.action === "pull" ? "Git could not pull the latest changes." : "Git could not fetch the latest changes.")
|
|
1914
1914
|
|
|
1915
|
-
if (
|
|
1916
|
-
title = "Pull requires merge or rebase"
|
|
1917
|
-
message = "Your branch cannot be fast-forwarded. Rebase or merge manually, then try again."
|
|
1918
|
-
} else if (normalized.includes("could not read from remote repository") || normalized.includes("authentication failed") || normalized.includes("permission denied")) {
|
|
1915
|
+
if (normalized.includes("could not read from remote repository") || normalized.includes("authentication failed") || normalized.includes("permission denied")) {
|
|
1919
1916
|
title = "Remote authentication failed"
|
|
1920
1917
|
message = "Git could not authenticate with the remote repository."
|
|
1921
1918
|
}
|