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.
- package/README.md +32 -10
- package/dist/client/assets/index-5ura1eo0.js +419 -0
- package/dist/client/assets/index-B0Cwdy1-.css +1 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/server/agent.test.ts +297 -1
- package/src/server/agent.ts +56 -8
- package/src/server/cli-runtime.test.ts +180 -0
- package/src/server/cli-runtime.ts +274 -0
- package/src/server/cli.ts +20 -127
- package/src/server/codex-app-server.test.ts +236 -0
- package/src/server/codex-app-server.ts +68 -2
- package/src/server/discovery.test.ts +211 -0
- package/src/server/discovery.ts +253 -17
- package/src/server/generate-title.ts +32 -39
- package/src/server/quick-response.test.ts +86 -0
- package/src/server/quick-response.ts +124 -0
- package/src/server/read-models.test.ts +43 -1
- package/src/server/server.ts +5 -3
- package/src/server/ws-router.test.ts +47 -0
- package/src/server/ws-router.ts +4 -0
- package/src/shared/protocol.ts +1 -0
- package/src/shared/tools.test.ts +12 -1
- package/src/shared/tools.ts +19 -1
- package/src/shared/types.ts +5 -1
- package/dist/client/assets/index-C-sGbl7X.js +0 -409
- package/dist/client/assets/index-gld9RxCU.css +0 -1
|
@@ -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:
|
|
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
|
|
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
|
+
})
|