kanna-code 0.2.0 → 0.4.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.
@@ -181,6 +181,56 @@ describe("CodexAppServerManager", () => {
181
181
  expect(turnStart?.params.collaborationMode?.settings?.reasoning_effort).toBeNull()
182
182
  })
183
183
 
184
+ test("generateStructured returns the final assistant JSON and stops the transient session", async () => {
185
+ const process = new FakeCodexProcess((message, child) => {
186
+ if (message.method === "initialize") {
187
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
188
+ } else if (message.method === "thread/start") {
189
+ child.writeServerMessage({
190
+ id: message.id,
191
+ result: { thread: { id: "thread-structured" }, model: "gpt-5.4", reasoningEffort: "high" },
192
+ })
193
+ } else if (message.method === "turn/start") {
194
+ child.writeServerMessage({
195
+ id: message.id,
196
+ result: { turn: { id: "turn-structured", status: "completed", error: null } },
197
+ })
198
+ child.writeServerMessage({
199
+ method: "item/completed",
200
+ params: {
201
+ threadId: "thread-structured",
202
+ turnId: "turn-structured",
203
+ item: {
204
+ type: "agentMessage",
205
+ id: "msg-structured",
206
+ text: "{\"title\":\"Codex title\"}",
207
+ phase: "final_answer",
208
+ },
209
+ },
210
+ })
211
+ child.writeServerMessage({
212
+ method: "turn/completed",
213
+ params: {
214
+ threadId: "thread-structured",
215
+ turn: { id: "turn-structured", status: "completed", error: null },
216
+ },
217
+ })
218
+ }
219
+ })
220
+
221
+ const manager = new CodexAppServerManager({
222
+ spawnProcess: () => process as never,
223
+ })
224
+
225
+ const result = await manager.generateStructured({
226
+ cwd: "/tmp/project",
227
+ prompt: "Return JSON",
228
+ })
229
+
230
+ expect(result).toBe("{\"title\":\"Codex title\"}")
231
+ expect(process.killed).toBe(true)
232
+ })
233
+
184
234
  test("maps command execution and agent output into the shared transcript stream", async () => {
185
235
  const process = new FakeCodexProcess((message, child) => {
186
236
  if (message.method === "initialize") {
@@ -1136,6 +1186,104 @@ describe("CodexAppServerManager", () => {
1136
1186
  })
1137
1187
  })
1138
1188
 
1189
+ test("infers multi-select Codex questions from prompt text and returns multiple answers", async () => {
1190
+ const process = new FakeCodexProcess((message, child) => {
1191
+ if (message.method === "initialize") {
1192
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
1193
+ } else if (message.method === "thread/start") {
1194
+ child.writeServerMessage({
1195
+ id: message.id,
1196
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
1197
+ })
1198
+ } else if (message.method === "turn/start") {
1199
+ child.writeServerMessage({
1200
+ id: message.id,
1201
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
1202
+ })
1203
+ child.writeServerMessage({
1204
+ id: "req-1",
1205
+ method: "item/tool/requestUserInput",
1206
+ params: {
1207
+ threadId: "thread-1",
1208
+ turnId: "turn-1",
1209
+ itemId: "ask-1",
1210
+ questions: [
1211
+ {
1212
+ id: "runtimes",
1213
+ header: "Runtime",
1214
+ question: "Select all runtimes that apply",
1215
+ isOther: true,
1216
+ isSecret: false,
1217
+ options: [
1218
+ { label: "bun", description: null },
1219
+ { label: "node", description: null },
1220
+ ],
1221
+ },
1222
+ ],
1223
+ },
1224
+ })
1225
+ child.writeServerMessage({
1226
+ method: "turn/completed",
1227
+ params: {
1228
+ threadId: "thread-1",
1229
+ turn: { id: "turn-1", status: "completed", error: null },
1230
+ },
1231
+ })
1232
+ }
1233
+ })
1234
+
1235
+ const manager = new CodexAppServerManager({
1236
+ spawnProcess: () => process as never,
1237
+ })
1238
+
1239
+ await manager.startSession({
1240
+ chatId: "chat-1",
1241
+ cwd: "/tmp/project",
1242
+ model: "gpt-5.4",
1243
+ sessionToken: null,
1244
+ })
1245
+
1246
+ const turn = await manager.startTurn({
1247
+ chatId: "chat-1",
1248
+ model: "gpt-5.4",
1249
+ content: "ask me",
1250
+ planMode: false,
1251
+ onToolRequest: async ({ tool }) => {
1252
+ expect(tool.toolKind).toBe("ask_user_question")
1253
+ if (tool.toolKind !== "ask_user_question") {
1254
+ return {}
1255
+ }
1256
+
1257
+ expect(tool.input.questions[0]?.multiSelect).toBe(true)
1258
+
1259
+ return {
1260
+ questions: [{
1261
+ id: "runtimes",
1262
+ question: "Select all runtimes that apply",
1263
+ multiSelect: true,
1264
+ }],
1265
+ answers: {
1266
+ runtimes: ["bun", "node"],
1267
+ },
1268
+ }
1269
+ },
1270
+ })
1271
+
1272
+ await collectStream(turn.stream)
1273
+
1274
+ const response = process.messages.find((message: any) => message.id === "req-1")
1275
+ expect(response).toEqual({
1276
+ id: "req-1",
1277
+ result: {
1278
+ answers: {
1279
+ runtimes: {
1280
+ answers: ["bun", "node"],
1281
+ },
1282
+ },
1283
+ },
1284
+ })
1285
+ })
1286
+
1139
1287
  test("sends approval decisions back to the app-server", async () => {
1140
1288
  const process = new FakeCodexProcess((message, child) => {
1141
1289
  if (message.method === "initialize") {
@@ -1257,6 +1405,94 @@ describe("CodexAppServerManager", () => {
1257
1405
  })
1258
1406
  })
1259
1407
 
1408
+ test("interrupt clears a pending exit-plan wait so a new turn can start immediately", async () => {
1409
+ let resolveToolRequest!: (value: unknown) => void
1410
+
1411
+ const process = new FakeCodexProcess((message, child) => {
1412
+ if (message.method === "initialize") {
1413
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
1414
+ } else if (message.method === "thread/start") {
1415
+ child.writeServerMessage({
1416
+ id: message.id,
1417
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
1418
+ })
1419
+ } else if (message.method === "turn/start") {
1420
+ if (message.params.input[0]?.text === "make a plan") {
1421
+ child.writeServerMessage({
1422
+ id: message.id,
1423
+ result: { turn: { id: "turn-plan", status: "completed", error: null } },
1424
+ })
1425
+ child.writeServerMessage({
1426
+ method: "turn/plan/updated",
1427
+ params: {
1428
+ threadId: "thread-1",
1429
+ turnId: "turn-plan",
1430
+ explanation: "Plan the work",
1431
+ plan: [{ step: "Inspect repo", status: "completed" }],
1432
+ },
1433
+ })
1434
+ child.writeServerMessage({
1435
+ method: "turn/completed",
1436
+ params: {
1437
+ threadId: "thread-1",
1438
+ turn: { id: "turn-plan", status: "completed", error: null },
1439
+ },
1440
+ })
1441
+ } else {
1442
+ child.writeServerMessage({
1443
+ id: message.id,
1444
+ result: { turn: { id: "turn-next", status: "completed", error: null } },
1445
+ })
1446
+ child.writeServerMessage({
1447
+ method: "turn/completed",
1448
+ params: {
1449
+ threadId: "thread-1",
1450
+ turn: { id: "turn-next", status: "completed", error: null },
1451
+ },
1452
+ })
1453
+ }
1454
+ }
1455
+ })
1456
+
1457
+ const manager = new CodexAppServerManager({
1458
+ spawnProcess: () => process as never,
1459
+ })
1460
+
1461
+ await manager.startSession({
1462
+ chatId: "chat-1",
1463
+ cwd: "/tmp/project",
1464
+ model: "gpt-5.4",
1465
+ sessionToken: null,
1466
+ })
1467
+
1468
+ const turn = await manager.startTurn({
1469
+ chatId: "chat-1",
1470
+ model: "gpt-5.4",
1471
+ content: "make a plan",
1472
+ planMode: true,
1473
+ onToolRequest: async () => await new Promise((resolve) => {
1474
+ resolveToolRequest = resolve
1475
+ }),
1476
+ })
1477
+
1478
+ const iterator = turn.stream[Symbol.asyncIterator]()
1479
+ await iterator.next()
1480
+ await iterator.next()
1481
+ await iterator.next()
1482
+ await turn.interrupt()
1483
+
1484
+ const nextTurn = await manager.startTurn({
1485
+ chatId: "chat-1",
1486
+ model: "gpt-5.4",
1487
+ content: "continue",
1488
+ planMode: false,
1489
+ onToolRequest: async () => ({}),
1490
+ })
1491
+
1492
+ await collectStream(nextTurn.stream)
1493
+ resolveToolRequest({})
1494
+ })
1495
+
1260
1496
  test("emits an error result when the app-server exits mid-turn", async () => {
1261
1497
  const process = new FakeCodexProcess((message, child) => {
1262
1498
  if (message.method === "initialize") {
@@ -30,6 +30,7 @@ import {
30
30
  type ThreadStartParams,
31
31
  type ThreadStartResponse,
32
32
  type ToolRequestUserInputParams,
33
+ type ToolRequestUserInputQuestion,
33
34
  type ToolRequestUserInputResponse,
34
35
  type TurnPlanStep,
35
36
  type TurnPlanUpdatedNotification,
@@ -122,6 +123,14 @@ export interface StartCodexTurnArgs {
122
123
  onApprovalRequest?: PendingTurn["onApprovalRequest"]
123
124
  }
124
125
 
126
+ export interface GenerateStructuredArgs {
127
+ cwd: string
128
+ prompt: string
129
+ model?: string
130
+ effort?: CodexReasoningEffort
131
+ serviceTier?: ServiceTier
132
+ }
133
+
125
134
  function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
126
135
  entry: T,
127
136
  createdAt = Date.now()
@@ -166,6 +175,13 @@ function isRecoverableResumeError(error: unknown): boolean {
166
175
  )
167
176
  }
168
177
 
178
+ const MULTI_SELECT_HINT_PATTERN = /\b(all that apply|select all|choose all|pick all|select multiple|choose multiple|pick multiple|multiple selections?|multiple choice|more than one|one or more)\b/i
179
+
180
+ function inferQuestionAllowsMultiple(question: ToolRequestUserInputQuestion): boolean {
181
+ const combinedText = [question.header, question.question].filter(Boolean).join(" ")
182
+ return MULTI_SELECT_HINT_PATTERN.test(combinedText)
183
+ }
184
+
169
185
  function toAskUserQuestionItems(params: ToolRequestUserInputParams): AskUserQuestionItem[] {
170
186
  return params.questions.map((question) => ({
171
187
  id: question.id,
@@ -175,7 +191,7 @@ function toAskUserQuestionItems(params: ToolRequestUserInputParams): AskUserQues
175
191
  label: option.label,
176
192
  description: option.description ?? undefined,
177
193
  })),
178
- multiSelect: false,
194
+ multiSelect: inferQuestionAllowsMultiple(question),
179
195
  }))
180
196
  }
181
197
 
@@ -774,7 +790,14 @@ export class CodexAppServerManager {
774
790
  stream: queue,
775
791
  interrupt: async () => {
776
792
  const pendingTurn = context.pendingTurn
777
- if (!pendingTurn?.turnId || !context.sessionToken) return
793
+ if (!pendingTurn) return
794
+
795
+ context.pendingTurn = null
796
+ pendingTurn.resolved = true
797
+ pendingTurn.queue.finish()
798
+
799
+ if (!pendingTurn.turnId || !context.sessionToken) return
800
+
778
801
  await this.sendRequest(context, "turn/interrupt", {
779
802
  threadId: context.sessionToken,
780
803
  turnId: pendingTurn.turnId,
@@ -784,6 +807,49 @@ export class CodexAppServerManager {
784
807
  }
785
808
  }
786
809
 
810
+ async generateStructured(args: GenerateStructuredArgs): Promise<string | null> {
811
+ const chatId = `quick-${randomUUID()}`
812
+ let turn: HarnessTurn | null = null
813
+ let assistantText = ""
814
+ let resultText = ""
815
+
816
+ try {
817
+ await this.startSession({
818
+ chatId,
819
+ cwd: args.cwd,
820
+ model: args.model ?? "gpt-5.4",
821
+ serviceTier: args.serviceTier ?? "fast",
822
+ sessionToken: null,
823
+ })
824
+
825
+ turn = await this.startTurn({
826
+ chatId,
827
+ model: args.model ?? "gpt-5.4",
828
+ effort: args.effort,
829
+ serviceTier: args.serviceTier ?? "fast",
830
+ content: args.prompt,
831
+ planMode: false,
832
+ onToolRequest: async () => ({}),
833
+ })
834
+
835
+ for await (const event of turn.stream) {
836
+ if (event.type !== "transcript" || !event.entry) continue
837
+ if (event.entry.kind === "assistant_text") {
838
+ assistantText += assistantText ? `\n${event.entry.text}` : event.entry.text
839
+ }
840
+ if (event.entry.kind === "result" && !event.entry.isError && event.entry.result.trim()) {
841
+ resultText = event.entry.result
842
+ }
843
+ }
844
+
845
+ const candidate = assistantText.trim() || resultText.trim()
846
+ return candidate || null
847
+ } finally {
848
+ turn?.close()
849
+ this.stopSession(chatId)
850
+ }
851
+ }
852
+
787
853
  stopSession(chatId: string) {
788
854
  const context = this.sessions.get(chatId)
789
855
  if (!context) return
@@ -0,0 +1,211 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import {
6
+ ClaudeProjectDiscoveryAdapter,
7
+ CodexProjectDiscoveryAdapter,
8
+ discoverProjects,
9
+ type ProjectDiscoveryAdapter,
10
+ } from "./discovery"
11
+
12
+ const tempDirs: string[] = []
13
+
14
+ function makeTempDir() {
15
+ const directory = mkdtempSync(path.join(tmpdir(), "kanna-discovery-"))
16
+ tempDirs.push(directory)
17
+ return directory
18
+ }
19
+
20
+ function encodeClaudeProjectPath(localPath: string) {
21
+ return `-${localPath.replace(/\//g, "-")}`
22
+ }
23
+
24
+ afterEach(() => {
25
+ for (const directory of tempDirs.splice(0)) {
26
+ rmSync(directory, { recursive: true, force: true })
27
+ }
28
+ })
29
+
30
+ describe("project discovery", () => {
31
+ test("Claude adapter decodes saved project paths", () => {
32
+ const homeDir = makeTempDir()
33
+ const projectDir = path.join(homeDir, "workspace", "alpha-project")
34
+ const claudeProjectsDir = path.join(homeDir, ".claude", "projects")
35
+ const projectMarkerDir = path.join(claudeProjectsDir, encodeClaudeProjectPath(projectDir))
36
+
37
+ mkdirSync(projectDir, { recursive: true })
38
+ mkdirSync(projectMarkerDir, { recursive: true })
39
+ utimesSync(projectMarkerDir, new Date("2026-03-16T10:00:00.000Z"), new Date("2026-03-16T10:00:00.000Z"))
40
+
41
+ const projects = new ClaudeProjectDiscoveryAdapter().scan(homeDir)
42
+
43
+ expect(projects).toEqual([
44
+ {
45
+ provider: "claude",
46
+ localPath: projectDir,
47
+ title: "alpha-project",
48
+ modifiedAt: new Date("2026-03-16T10:00:00.000Z").getTime(),
49
+ },
50
+ ])
51
+ })
52
+
53
+ test("Codex adapter reads cwd from session metadata and ignores stale or invalid entries", () => {
54
+ const homeDir = makeTempDir()
55
+ const sessionsDir = path.join(homeDir, ".codex", "sessions", "2026", "03", "16")
56
+ const liveProjectDir = path.join(homeDir, "workspace", "kanna")
57
+ const missingProjectDir = path.join(homeDir, "workspace", "missing-project")
58
+ mkdirSync(liveProjectDir, { recursive: true })
59
+ mkdirSync(sessionsDir, { recursive: true })
60
+
61
+ writeFileSync(path.join(homeDir, ".codex", "session_index.jsonl"), [
62
+ JSON.stringify({
63
+ id: "session-live",
64
+ updated_at: "2026-03-16T23:05:58.940134Z",
65
+ }),
66
+ JSON.stringify({
67
+ id: "session-missing",
68
+ updated_at: "2026-03-16T20:05:58.940134Z",
69
+ }),
70
+ JSON.stringify({
71
+ id: "session-relative",
72
+ updated_at: "2026-03-16T21:05:58.940134Z",
73
+ }),
74
+ ].join("\n"))
75
+
76
+ writeFileSync(path.join(sessionsDir, "rollout-2026-03-16T23-05-52-session-live.jsonl"), [
77
+ JSON.stringify({
78
+ timestamp: "2026-03-16T23:05:52.000Z",
79
+ type: "session_meta",
80
+ payload: {
81
+ id: "session-live",
82
+ cwd: liveProjectDir,
83
+ },
84
+ }),
85
+ ].join("\n"))
86
+
87
+ writeFileSync(path.join(sessionsDir, "rollout-2026-03-16T20-05-52-session-missing.jsonl"), [
88
+ JSON.stringify({
89
+ timestamp: "2026-03-16T20:05:52.000Z",
90
+ type: "session_meta",
91
+ payload: {
92
+ id: "session-missing",
93
+ cwd: missingProjectDir,
94
+ },
95
+ }),
96
+ ].join("\n"))
97
+
98
+ writeFileSync(path.join(sessionsDir, "rollout-2026-03-16T21-05-52-session-relative.jsonl"), [
99
+ JSON.stringify({
100
+ timestamp: "2026-03-16T21:05:52.000Z",
101
+ type: "session_meta",
102
+ payload: {
103
+ id: "session-relative",
104
+ cwd: "./relative-path",
105
+ },
106
+ }),
107
+ ].join("\n"))
108
+
109
+ const projects = new CodexProjectDiscoveryAdapter().scan(homeDir)
110
+
111
+ expect(projects).toEqual([
112
+ {
113
+ provider: "codex",
114
+ localPath: liveProjectDir,
115
+ title: "kanna",
116
+ modifiedAt: Date.parse("2026-03-16T23:05:58.940134Z"),
117
+ },
118
+ ])
119
+ })
120
+
121
+ test("Codex adapter falls back to session timestamps and config projects when session index misses CLI entries", () => {
122
+ const homeDir = makeTempDir()
123
+ const sessionsDir = path.join(homeDir, ".codex", "sessions", "2026", "03", "16")
124
+ const cliProjectDir = path.join(homeDir, "workspace", "codex-test-2")
125
+ const configOnlyProjectDir = path.join(homeDir, "workspace", "config-only")
126
+ mkdirSync(cliProjectDir, { recursive: true })
127
+ mkdirSync(configOnlyProjectDir, { recursive: true })
128
+ mkdirSync(sessionsDir, { recursive: true })
129
+
130
+ writeFileSync(path.join(homeDir, ".codex", "session_index.jsonl"), "")
131
+ writeFileSync(path.join(homeDir, ".codex", "config.toml"), [
132
+ `personality = "pragmatic"`,
133
+ `[projects."${configOnlyProjectDir}"]`,
134
+ `trust_level = "trusted"`,
135
+ ].join("\n"))
136
+
137
+ writeFileSync(path.join(sessionsDir, "rollout-2026-03-16T23-42-24-cli-session.jsonl"), [
138
+ JSON.stringify({
139
+ timestamp: "2026-03-17T03:42:25.751Z",
140
+ type: "session_meta",
141
+ payload: {
142
+ id: "cli-session",
143
+ timestamp: "2026-03-17T03:42:24.578Z",
144
+ cwd: cliProjectDir,
145
+ originator: "codex-tui",
146
+ source: "cli",
147
+ },
148
+ }),
149
+ ].join("\n"))
150
+
151
+ const projects = new CodexProjectDiscoveryAdapter().scan(homeDir)
152
+
153
+ expect(projects.map((project) => project.localPath).sort()).toEqual([
154
+ cliProjectDir,
155
+ configOnlyProjectDir,
156
+ ].sort())
157
+ expect(projects.find((project) => project.localPath === cliProjectDir)?.modifiedAt).toBe(
158
+ Date.parse("2026-03-17T03:42:25.751Z")
159
+ )
160
+ })
161
+
162
+ test("discoverProjects de-dupes provider results by normalized path and keeps the newest timestamp", () => {
163
+ const adapters: ProjectDiscoveryAdapter[] = [
164
+ {
165
+ provider: "claude",
166
+ scan() {
167
+ return [
168
+ {
169
+ provider: "claude",
170
+ localPath: "/tmp/project",
171
+ title: "Claude Project",
172
+ modifiedAt: 10,
173
+ },
174
+ ]
175
+ },
176
+ },
177
+ {
178
+ provider: "codex",
179
+ scan() {
180
+ return [
181
+ {
182
+ provider: "codex",
183
+ localPath: "/tmp/project",
184
+ title: "Codex Project",
185
+ modifiedAt: 20,
186
+ },
187
+ {
188
+ provider: "codex",
189
+ localPath: "/tmp/other-project",
190
+ title: "Other Project",
191
+ modifiedAt: 15,
192
+ },
193
+ ]
194
+ },
195
+ },
196
+ ]
197
+
198
+ expect(discoverProjects("/unused-home", adapters)).toEqual([
199
+ {
200
+ localPath: "/tmp/project",
201
+ title: "Codex Project",
202
+ modifiedAt: 20,
203
+ },
204
+ {
205
+ localPath: "/tmp/other-project",
206
+ title: "Other Project",
207
+ modifiedAt: 15,
208
+ },
209
+ ])
210
+ })
211
+ })