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.
@@ -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 [] as TranscriptEntry[]
512
+ return this.messages
387
513
  },
388
514
  async setChatProvider(_chatId: string, provider: "claude" | "codex") {
389
515
  chat.provider = provider
@@ -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
- if (args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0) {
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
+ })