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.
@@ -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)
@@ -1903,7 +1903,7 @@ export class DiffStore {
1903
1903
  }
1904
1904
 
1905
1905
  const syncResult = args.action === "pull"
1906
- ? await runGit(["pull", "--ff-only"], repo.repoRoot)
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 (args.action === "pull" && normalized.includes("not possible to fast-forward")) {
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
  }