kanna-code 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -10
- package/dist/client/assets/{index-C-sGbl7X.js → index-Byzgv_-q.js} +2 -2
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/server/agent.test.ts +127 -1
- package/src/server/agent.ts +24 -8
- package/src/server/codex-app-server.test.ts +50 -0
- package/src/server/codex-app-server.ts +51 -0
- package/src/server/discovery.test.ts +211 -0
- package/src/server/discovery.ts +292 -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 +2 -2
package/src/server/agent.test.ts
CHANGED
|
@@ -64,6 +64,132 @@ describe("normalizeClaudeStreamMessage", () => {
|
|
|
64
64
|
})
|
|
65
65
|
|
|
66
66
|
describe("AgentCoordinator codex integration", () => {
|
|
67
|
+
test("generates a chat title in the background on the first user message", async () => {
|
|
68
|
+
const fakeCodexManager = {
|
|
69
|
+
async startSession() {},
|
|
70
|
+
async startTurn(): Promise<HarnessTurn> {
|
|
71
|
+
async function* stream() {
|
|
72
|
+
yield {
|
|
73
|
+
type: "transcript" as const,
|
|
74
|
+
entry: timestamped({
|
|
75
|
+
kind: "system_init",
|
|
76
|
+
provider: "codex",
|
|
77
|
+
model: "gpt-5.4",
|
|
78
|
+
tools: [],
|
|
79
|
+
agents: [],
|
|
80
|
+
slashCommands: [],
|
|
81
|
+
mcpServers: [],
|
|
82
|
+
}),
|
|
83
|
+
}
|
|
84
|
+
yield {
|
|
85
|
+
type: "transcript" as const,
|
|
86
|
+
entry: timestamped({
|
|
87
|
+
kind: "result",
|
|
88
|
+
subtype: "success",
|
|
89
|
+
isError: false,
|
|
90
|
+
durationMs: 0,
|
|
91
|
+
result: "",
|
|
92
|
+
}),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
provider: "codex",
|
|
98
|
+
stream: stream(),
|
|
99
|
+
interrupt: async () => {},
|
|
100
|
+
close: () => {},
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const store = createFakeStore()
|
|
106
|
+
const coordinator = new AgentCoordinator({
|
|
107
|
+
store: store as never,
|
|
108
|
+
onStateChange: () => {},
|
|
109
|
+
codexManager: fakeCodexManager as never,
|
|
110
|
+
generateTitle: async () => "Generated title",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
await coordinator.send({
|
|
114
|
+
type: "chat.send",
|
|
115
|
+
chatId: "chat-1",
|
|
116
|
+
provider: "codex",
|
|
117
|
+
content: "first message",
|
|
118
|
+
model: "gpt-5.4",
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await waitFor(() => store.chat.title === "Generated title")
|
|
122
|
+
expect(store.messages[0]?.kind).toBe("user_prompt")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("does not overwrite a manual rename when background title generation finishes later", async () => {
|
|
126
|
+
let releaseTitle!: () => void
|
|
127
|
+
const titleGate = new Promise<void>((resolve) => {
|
|
128
|
+
releaseTitle = resolve
|
|
129
|
+
})
|
|
130
|
+
const fakeCodexManager = {
|
|
131
|
+
async startSession() {},
|
|
132
|
+
async startTurn(): Promise<HarnessTurn> {
|
|
133
|
+
async function* stream() {
|
|
134
|
+
yield {
|
|
135
|
+
type: "transcript" as const,
|
|
136
|
+
entry: timestamped({
|
|
137
|
+
kind: "system_init",
|
|
138
|
+
provider: "codex",
|
|
139
|
+
model: "gpt-5.4",
|
|
140
|
+
tools: [],
|
|
141
|
+
agents: [],
|
|
142
|
+
slashCommands: [],
|
|
143
|
+
mcpServers: [],
|
|
144
|
+
}),
|
|
145
|
+
}
|
|
146
|
+
yield {
|
|
147
|
+
type: "transcript" as const,
|
|
148
|
+
entry: timestamped({
|
|
149
|
+
kind: "result",
|
|
150
|
+
subtype: "success",
|
|
151
|
+
isError: false,
|
|
152
|
+
durationMs: 0,
|
|
153
|
+
result: "",
|
|
154
|
+
}),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
provider: "codex",
|
|
160
|
+
stream: stream(),
|
|
161
|
+
interrupt: async () => {},
|
|
162
|
+
close: () => {},
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const store = createFakeStore()
|
|
168
|
+
const coordinator = new AgentCoordinator({
|
|
169
|
+
store: store as never,
|
|
170
|
+
onStateChange: () => {},
|
|
171
|
+
codexManager: fakeCodexManager as never,
|
|
172
|
+
generateTitle: async () => {
|
|
173
|
+
await titleGate
|
|
174
|
+
return "Generated title"
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
await coordinator.send({
|
|
179
|
+
type: "chat.send",
|
|
180
|
+
chatId: "chat-1",
|
|
181
|
+
provider: "codex",
|
|
182
|
+
content: "first message",
|
|
183
|
+
model: "gpt-5.4",
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
await store.renameChat("chat-1", "Manual title")
|
|
187
|
+
releaseTitle()
|
|
188
|
+
await waitFor(() => store.turnFinishedCount === 1)
|
|
189
|
+
|
|
190
|
+
expect(store.chat.title).toBe("Manual title")
|
|
191
|
+
})
|
|
192
|
+
|
|
67
193
|
test("binds codex provider and reuses the session token on later turns", async () => {
|
|
68
194
|
const sessionCalls: Array<{ chatId: string; sessionToken: string | null }> = []
|
|
69
195
|
const fakeCodexManager = {
|
|
@@ -383,7 +509,7 @@ function createFakeStore() {
|
|
|
383
509
|
return project
|
|
384
510
|
},
|
|
385
511
|
getMessages() {
|
|
386
|
-
return
|
|
512
|
+
return this.messages
|
|
387
513
|
},
|
|
388
514
|
async setChatProvider(_chatId: string, provider: "claude" | "codex") {
|
|
389
515
|
chat.provider = provider
|
package/src/server/agent.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { normalizeToolCall } from "../shared/tools"
|
|
|
10
10
|
import type { ClientCommand } from "../shared/protocol"
|
|
11
11
|
import { EventStore } from "./event-store"
|
|
12
12
|
import { CodexAppServerManager } from "./codex-app-server"
|
|
13
|
+
import { generateTitleForChat } from "./generate-title"
|
|
13
14
|
import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
|
|
14
15
|
import {
|
|
15
16
|
codexServiceTierFromModelOptions,
|
|
@@ -64,11 +65,7 @@ interface AgentCoordinatorArgs {
|
|
|
64
65
|
store: EventStore
|
|
65
66
|
onStateChange: () => void
|
|
66
67
|
codexManager?: CodexAppServerManager
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
function deriveChatTitle(content: string) {
|
|
70
|
-
const singleLine = content.replace(/\s+/g, " ").trim()
|
|
71
|
-
return singleLine.slice(0, 60) || "New Chat"
|
|
68
|
+
generateTitle?: (messageContent: string, cwd: string) => Promise<string | null>
|
|
72
69
|
}
|
|
73
70
|
|
|
74
71
|
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
|
|
@@ -321,12 +318,14 @@ export class AgentCoordinator {
|
|
|
321
318
|
private readonly store: EventStore
|
|
322
319
|
private readonly onStateChange: () => void
|
|
323
320
|
private readonly codexManager: CodexAppServerManager
|
|
321
|
+
private readonly generateTitle: (messageContent: string, cwd: string) => Promise<string | null>
|
|
324
322
|
readonly activeTurns = new Map<string, ActiveTurn>()
|
|
325
323
|
|
|
326
324
|
constructor(args: AgentCoordinatorArgs) {
|
|
327
325
|
this.store = args.store
|
|
328
326
|
this.onStateChange = args.onStateChange
|
|
329
327
|
this.codexManager = args.codexManager ?? new CodexAppServerManager()
|
|
328
|
+
this.generateTitle = args.generateTitle ?? generateTitleForChat
|
|
330
329
|
}
|
|
331
330
|
|
|
332
331
|
getActiveStatuses() {
|
|
@@ -390,9 +389,7 @@ export class AgentCoordinator {
|
|
|
390
389
|
await this.store.setPlanMode(args.chatId, args.planMode)
|
|
391
390
|
|
|
392
391
|
const existingMessages = this.store.getMessages(args.chatId)
|
|
393
|
-
|
|
394
|
-
await this.store.renameChat(args.chatId, deriveChatTitle(args.content))
|
|
395
|
-
}
|
|
392
|
+
const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
|
|
396
393
|
|
|
397
394
|
if (args.appendUserPrompt) {
|
|
398
395
|
await this.store.appendMessage(args.chatId, timestamped({ kind: "user_prompt", content: args.content }, Date.now()))
|
|
@@ -404,6 +401,10 @@ export class AgentCoordinator {
|
|
|
404
401
|
throw new Error("Project not found")
|
|
405
402
|
}
|
|
406
403
|
|
|
404
|
+
if (shouldGenerateTitle) {
|
|
405
|
+
void this.generateTitleInBackground(args.chatId, args.content, project.localPath)
|
|
406
|
+
}
|
|
407
|
+
|
|
407
408
|
const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
|
|
408
409
|
const active = this.activeTurns.get(args.chatId)
|
|
409
410
|
if (!active) {
|
|
@@ -511,6 +512,21 @@ export class AgentCoordinator {
|
|
|
511
512
|
return { chatId }
|
|
512
513
|
}
|
|
513
514
|
|
|
515
|
+
private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string) {
|
|
516
|
+
try {
|
|
517
|
+
const title = await this.generateTitle(messageContent, cwd)
|
|
518
|
+
if (!title) return
|
|
519
|
+
|
|
520
|
+
const chat = this.store.requireChat(chatId)
|
|
521
|
+
if (chat.title !== "New Chat") return
|
|
522
|
+
|
|
523
|
+
await this.store.renameChat(chatId, title)
|
|
524
|
+
this.onStateChange()
|
|
525
|
+
} catch {
|
|
526
|
+
// Ignore background title generation failures.
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
514
530
|
private async runTurn(active: ActiveTurn) {
|
|
515
531
|
try {
|
|
516
532
|
for await (const event of active.turn.stream) {
|
|
@@ -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") {
|
|
@@ -122,6 +122,14 @@ export interface StartCodexTurnArgs {
|
|
|
122
122
|
onApprovalRequest?: PendingTurn["onApprovalRequest"]
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
export interface GenerateStructuredArgs {
|
|
126
|
+
cwd: string
|
|
127
|
+
prompt: string
|
|
128
|
+
model?: string
|
|
129
|
+
effort?: CodexReasoningEffort
|
|
130
|
+
serviceTier?: ServiceTier
|
|
131
|
+
}
|
|
132
|
+
|
|
125
133
|
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
|
|
126
134
|
entry: T,
|
|
127
135
|
createdAt = Date.now()
|
|
@@ -784,6 +792,49 @@ export class CodexAppServerManager {
|
|
|
784
792
|
}
|
|
785
793
|
}
|
|
786
794
|
|
|
795
|
+
async generateStructured(args: GenerateStructuredArgs): Promise<string | null> {
|
|
796
|
+
const chatId = `quick-${randomUUID()}`
|
|
797
|
+
let turn: HarnessTurn | null = null
|
|
798
|
+
let assistantText = ""
|
|
799
|
+
let resultText = ""
|
|
800
|
+
|
|
801
|
+
try {
|
|
802
|
+
await this.startSession({
|
|
803
|
+
chatId,
|
|
804
|
+
cwd: args.cwd,
|
|
805
|
+
model: args.model ?? "gpt-5.4",
|
|
806
|
+
serviceTier: args.serviceTier ?? "fast",
|
|
807
|
+
sessionToken: null,
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
turn = await this.startTurn({
|
|
811
|
+
chatId,
|
|
812
|
+
model: args.model ?? "gpt-5.4",
|
|
813
|
+
effort: args.effort,
|
|
814
|
+
serviceTier: args.serviceTier ?? "fast",
|
|
815
|
+
content: args.prompt,
|
|
816
|
+
planMode: false,
|
|
817
|
+
onToolRequest: async () => ({}),
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
for await (const event of turn.stream) {
|
|
821
|
+
if (event.type !== "transcript" || !event.entry) continue
|
|
822
|
+
if (event.entry.kind === "assistant_text") {
|
|
823
|
+
assistantText += assistantText ? `\n${event.entry.text}` : event.entry.text
|
|
824
|
+
}
|
|
825
|
+
if (event.entry.kind === "result" && !event.entry.isError && event.entry.result.trim()) {
|
|
826
|
+
resultText = event.entry.result
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const candidate = assistantText.trim() || resultText.trim()
|
|
831
|
+
return candidate || null
|
|
832
|
+
} finally {
|
|
833
|
+
turn?.close()
|
|
834
|
+
this.stopSession(chatId)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
787
838
|
stopSession(chatId: string) {
|
|
788
839
|
const context = this.sessions.get(chatId)
|
|
789
840
|
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
|
+
})
|