kanna-code 0.32.4 → 0.33.1
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 +1 -1
- package/dist/client/assets/{index-CCCDQbjU.js → index-BLUOUI0z.js} +206 -196
- package/dist/client/assets/index-FQ1dOuTJ.css +32 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent.test.ts +88 -0
- package/src/server/agent.ts +44 -3
- package/src/server/auth.test.ts +131 -24
- package/src/server/auth.ts +47 -147
- package/src/server/cli-runtime.test.ts +4 -0
- package/src/server/cli-runtime.ts +2 -0
- package/src/server/codex-app-server-protocol.ts +12 -0
- package/src/server/codex-app-server.test.ts +32 -0
- package/src/server/codex-app-server.ts +17 -3
- package/src/server/diff-store.ts +0 -9
- package/src/server/event-store.test.ts +26 -0
- package/src/server/event-store.ts +74 -0
- package/src/server/events.ts +8 -0
- package/src/server/read-models.test.ts +60 -0
- package/src/server/read-models.ts +15 -0
- package/src/server/server.ts +11 -4
- package/src/server/ws-router.test.ts +134 -1
- package/src/server/ws-router.ts +7 -0
- package/src/shared/protocol.ts +1 -0
- package/src/shared/types.ts +1 -0
- package/dist/client/assets/index-DbTkHYDv.css +0 -32
package/src/server/diff-store.ts
CHANGED
|
@@ -121,15 +121,6 @@ type SelectedBranch =
|
|
|
121
121
|
remoteRef?: string
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
async function fileExists(filePath: string) {
|
|
125
|
-
try {
|
|
126
|
-
await stat(filePath)
|
|
127
|
-
return true
|
|
128
|
-
} catch {
|
|
129
|
-
return false
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
124
|
async function runGit(args: string[], cwd: string) {
|
|
134
125
|
const process = Bun.spawn(["git", "-C", cwd, ...args], {
|
|
135
126
|
stdout: "pipe",
|
|
@@ -511,4 +511,30 @@ describe("EventStore", () => {
|
|
|
511
511
|
expect(pruned).toEqual([])
|
|
512
512
|
expect(store.getChat(chat.id)?.id).toBe(chat.id)
|
|
513
513
|
})
|
|
514
|
+
|
|
515
|
+
test("forks a chat with copied transcript and pending fork session token", async () => {
|
|
516
|
+
const dataDir = await createTempDataDir()
|
|
517
|
+
const store = new EventStore(dataDir)
|
|
518
|
+
await store.initialize()
|
|
519
|
+
|
|
520
|
+
const project = await store.openProject("/tmp/project")
|
|
521
|
+
const source = await store.createChat(project.id)
|
|
522
|
+
await store.setChatProvider(source.id, "claude")
|
|
523
|
+
await store.setPlanMode(source.id, true)
|
|
524
|
+
await store.setSessionToken(source.id, "session-1")
|
|
525
|
+
await store.appendMessage(source.id, entry("user_prompt", source.createdAt + 1, { content: "analyze this" }))
|
|
526
|
+
await store.appendMessage(source.id, entry("assistant_text", source.createdAt + 2, { text: "done" }))
|
|
527
|
+
|
|
528
|
+
const forked = await store.forkChat(source.id)
|
|
529
|
+
|
|
530
|
+
expect(forked.id).not.toBe(source.id)
|
|
531
|
+
expect(forked.title).toBe("Fork: New Chat")
|
|
532
|
+
expect(forked.provider).toBe("claude")
|
|
533
|
+
expect(forked.planMode).toBe(true)
|
|
534
|
+
expect(forked.sessionToken).toBeNull()
|
|
535
|
+
expect(forked.pendingForkSessionToken).toBe("session-1")
|
|
536
|
+
expect(forked.lastTurnOutcome).toBeNull()
|
|
537
|
+
expect(forked.lastMessageAt).toBeUndefined()
|
|
538
|
+
expect(store.getMessages(forked.id)).toEqual(store.getMessages(source.id))
|
|
539
|
+
})
|
|
514
540
|
})
|
|
@@ -94,6 +94,8 @@ function getReplayEventPriority(event: StoreEvent) {
|
|
|
94
94
|
return 5
|
|
95
95
|
case "session_token_set":
|
|
96
96
|
return 6
|
|
97
|
+
case "pending_fork_session_token_set":
|
|
98
|
+
return 6
|
|
97
99
|
case "turn_cancelled":
|
|
98
100
|
return 7
|
|
99
101
|
case "turn_finished":
|
|
@@ -130,6 +132,12 @@ function getHistorySnapshot(page: TranscriptPageResult, recentLimit: number): Ch
|
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
function getForkedChatTitle(title: string) {
|
|
136
|
+
const trimmed = title.trim()
|
|
137
|
+
if (!trimmed) return "Fork: New Chat"
|
|
138
|
+
return trimmed.startsWith("Fork: ") ? trimmed : `Fork: ${trimmed}`
|
|
139
|
+
}
|
|
140
|
+
|
|
133
141
|
export class EventStore {
|
|
134
142
|
readonly dataDir: string
|
|
135
143
|
readonly state: StoreState = createEmptyState()
|
|
@@ -220,6 +228,7 @@ export class EventStore {
|
|
|
220
228
|
this.state.chatsById.set(chat.id, {
|
|
221
229
|
...chat,
|
|
222
230
|
unread: chat.unread ?? false,
|
|
231
|
+
pendingForkSessionToken: chat.pendingForkSessionToken ?? null,
|
|
223
232
|
})
|
|
224
233
|
}
|
|
225
234
|
this.legacySidebarProjectOrder = normalizeSidebarProjectOrder(parsed.sidebarProjectOrder)
|
|
@@ -443,6 +452,7 @@ export class EventStore {
|
|
|
443
452
|
provider: null,
|
|
444
453
|
planMode: false,
|
|
445
454
|
sessionToken: null,
|
|
455
|
+
pendingForkSessionToken: null,
|
|
446
456
|
hasMessages: false,
|
|
447
457
|
lastTurnOutcome: null,
|
|
448
458
|
}
|
|
@@ -555,6 +565,13 @@ export class EventStore {
|
|
|
555
565
|
chat.updatedAt = event.timestamp
|
|
556
566
|
break
|
|
557
567
|
}
|
|
568
|
+
case "pending_fork_session_token_set": {
|
|
569
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
570
|
+
if (!chat) break
|
|
571
|
+
chat.pendingForkSessionToken = event.pendingForkSessionToken
|
|
572
|
+
chat.updatedAt = event.timestamp
|
|
573
|
+
break
|
|
574
|
+
}
|
|
558
575
|
}
|
|
559
576
|
}
|
|
560
577
|
|
|
@@ -677,6 +694,50 @@ export class EventStore {
|
|
|
677
694
|
return this.state.chatsById.get(chatId)!
|
|
678
695
|
}
|
|
679
696
|
|
|
697
|
+
async forkChat(sourceChatId: string) {
|
|
698
|
+
const sourceChat = this.requireChat(sourceChatId)
|
|
699
|
+
const sourceSessionToken = sourceChat.sessionToken ?? sourceChat.pendingForkSessionToken ?? null
|
|
700
|
+
if (!sourceChat.provider || !sourceSessionToken) {
|
|
701
|
+
throw new Error("Chat cannot be forked")
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const chatId = crypto.randomUUID()
|
|
705
|
+
const createdAt = Date.now()
|
|
706
|
+
const createEvent: ChatEvent = {
|
|
707
|
+
v: STORE_VERSION,
|
|
708
|
+
type: "chat_created",
|
|
709
|
+
timestamp: createdAt,
|
|
710
|
+
chatId,
|
|
711
|
+
projectId: sourceChat.projectId,
|
|
712
|
+
title: getForkedChatTitle(sourceChat.title),
|
|
713
|
+
}
|
|
714
|
+
await this.append(this.chatsLogPath, createEvent)
|
|
715
|
+
await this.setChatProvider(chatId, sourceChat.provider)
|
|
716
|
+
await this.setPlanMode(chatId, sourceChat.planMode)
|
|
717
|
+
await this.setPendingForkSessionToken(chatId, sourceSessionToken)
|
|
718
|
+
|
|
719
|
+
const sourceEntries = this.getMessages(sourceChatId)
|
|
720
|
+
if (sourceEntries.length > 0) {
|
|
721
|
+
const transcriptPath = this.transcriptPath(chatId)
|
|
722
|
+
const payload = sourceEntries.map((entry) => JSON.stringify(entry)).join("\n")
|
|
723
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
724
|
+
await mkdir(this.transcriptsDir, { recursive: true })
|
|
725
|
+
await writeFile(transcriptPath, `${payload}\n`, "utf8")
|
|
726
|
+
const chat = this.state.chatsById.get(chatId)
|
|
727
|
+
if (chat) {
|
|
728
|
+
chat.hasMessages = true
|
|
729
|
+
chat.updatedAt = Math.max(chat.updatedAt, createdAt)
|
|
730
|
+
}
|
|
731
|
+
if (this.cachedTranscript?.chatId === chatId) {
|
|
732
|
+
this.cachedTranscript = { chatId, entries: cloneTranscriptEntries(sourceEntries) }
|
|
733
|
+
}
|
|
734
|
+
})
|
|
735
|
+
await this.writeChain
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return this.state.chatsById.get(chatId)!
|
|
739
|
+
}
|
|
740
|
+
|
|
680
741
|
async renameChat(chatId: string, title: string) {
|
|
681
742
|
const trimmed = title.trim()
|
|
682
743
|
if (!trimmed) return
|
|
@@ -911,6 +972,19 @@ export class EventStore {
|
|
|
911
972
|
await this.append(this.turnsLogPath, event)
|
|
912
973
|
}
|
|
913
974
|
|
|
975
|
+
async setPendingForkSessionToken(chatId: string, pendingForkSessionToken: string | null) {
|
|
976
|
+
const chat = this.requireChat(chatId)
|
|
977
|
+
if ((chat.pendingForkSessionToken ?? null) === pendingForkSessionToken) return
|
|
978
|
+
const event: TurnEvent = {
|
|
979
|
+
v: STORE_VERSION,
|
|
980
|
+
type: "pending_fork_session_token_set",
|
|
981
|
+
timestamp: Date.now(),
|
|
982
|
+
chatId,
|
|
983
|
+
pendingForkSessionToken,
|
|
984
|
+
}
|
|
985
|
+
await this.append(this.turnsLogPath, event)
|
|
986
|
+
}
|
|
987
|
+
|
|
914
988
|
getProject(projectId: string) {
|
|
915
989
|
const project = this.state.projectsById.get(projectId)
|
|
916
990
|
if (!project || project.deletedAt) return null
|
package/src/server/events.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface ChatRecord {
|
|
|
15
15
|
provider: AgentProvider | null
|
|
16
16
|
planMode: boolean
|
|
17
17
|
sessionToken: string | null
|
|
18
|
+
pendingForkSessionToken?: string | null
|
|
18
19
|
hasMessages?: boolean
|
|
19
20
|
lastMessageAt?: number
|
|
20
21
|
lastTurnOutcome: "success" | "failed" | "cancelled" | null
|
|
@@ -152,6 +153,13 @@ export type TurnEvent =
|
|
|
152
153
|
chatId: string
|
|
153
154
|
sessionToken: string | null
|
|
154
155
|
}
|
|
156
|
+
| {
|
|
157
|
+
v: 2
|
|
158
|
+
type: "pending_fork_session_token_set"
|
|
159
|
+
timestamp: number
|
|
160
|
+
chatId: string
|
|
161
|
+
pendingForkSessionToken: string | null
|
|
162
|
+
}
|
|
155
163
|
|
|
156
164
|
export type StoreEvent = ProjectEvent | ChatEvent | MessageEvent | QueuedMessageEvent | TurnEvent
|
|
157
165
|
|
|
@@ -29,6 +29,7 @@ describe("read models", () => {
|
|
|
29
29
|
const sidebar = deriveSidebarData(state, new Map(), { nowMs: 1_000_000 })
|
|
30
30
|
expect(sidebar.projectGroups[0]?.chats[0]?.provider).toBe("codex")
|
|
31
31
|
expect(sidebar.projectGroups[0]?.chats[0]?.unread).toBe(true)
|
|
32
|
+
expect(sidebar.projectGroups[0]?.chats[0]?.canFork).toBe(true)
|
|
32
33
|
expect(sidebar.projectGroups[0]?.previewChats.map((chat) => chat.chatId)).toEqual(["chat-1"])
|
|
33
34
|
expect(sidebar.projectGroups[0]?.olderChats).toEqual([])
|
|
34
35
|
expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
|
|
@@ -246,4 +247,63 @@ describe("read models", () => {
|
|
|
246
247
|
expect(sidebar.projectGroups[0]?.olderChats.map((chat) => chat.chatId)).toEqual(["chat-2"])
|
|
247
248
|
expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
|
|
248
249
|
})
|
|
250
|
+
|
|
251
|
+
test("disables forking for active and draining chats, but allows pending fork chats", () => {
|
|
252
|
+
const state = createEmptyState()
|
|
253
|
+
state.projectsById.set("project-1", {
|
|
254
|
+
id: "project-1",
|
|
255
|
+
localPath: "/tmp/project",
|
|
256
|
+
title: "Project",
|
|
257
|
+
createdAt: 1,
|
|
258
|
+
updatedAt: 1,
|
|
259
|
+
})
|
|
260
|
+
state.projectIdsByPath.set("/tmp/project", "project-1")
|
|
261
|
+
state.chatsById.set("chat-active", {
|
|
262
|
+
id: "chat-active",
|
|
263
|
+
projectId: "project-1",
|
|
264
|
+
title: "Active",
|
|
265
|
+
createdAt: 1,
|
|
266
|
+
updatedAt: 1,
|
|
267
|
+
unread: false,
|
|
268
|
+
provider: "claude",
|
|
269
|
+
planMode: false,
|
|
270
|
+
sessionToken: "session-active",
|
|
271
|
+
lastTurnOutcome: null,
|
|
272
|
+
})
|
|
273
|
+
state.chatsById.set("chat-pending", {
|
|
274
|
+
id: "chat-pending",
|
|
275
|
+
projectId: "project-1",
|
|
276
|
+
title: "Pending fork",
|
|
277
|
+
createdAt: 2,
|
|
278
|
+
updatedAt: 2,
|
|
279
|
+
unread: false,
|
|
280
|
+
provider: "claude",
|
|
281
|
+
planMode: false,
|
|
282
|
+
sessionToken: null,
|
|
283
|
+
pendingForkSessionToken: "session-parent",
|
|
284
|
+
lastTurnOutcome: null,
|
|
285
|
+
})
|
|
286
|
+
state.chatsById.set("chat-draining", {
|
|
287
|
+
id: "chat-draining",
|
|
288
|
+
projectId: "project-1",
|
|
289
|
+
title: "Draining",
|
|
290
|
+
createdAt: 3,
|
|
291
|
+
updatedAt: 3,
|
|
292
|
+
unread: false,
|
|
293
|
+
provider: "codex",
|
|
294
|
+
planMode: false,
|
|
295
|
+
sessionToken: "thread-1",
|
|
296
|
+
lastTurnOutcome: null,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const sidebar = deriveSidebarData(
|
|
300
|
+
state,
|
|
301
|
+
new Map([["chat-active", "running"]]),
|
|
302
|
+
{ drainingChatIds: new Set(["chat-draining"]) }
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
expect(sidebar.projectGroups[0]?.chats.find((chat) => chat.chatId === "chat-active")?.canFork).toBeUndefined()
|
|
306
|
+
expect(sidebar.projectGroups[0]?.chats.find((chat) => chat.chatId === "chat-pending")?.canFork).toBe(true)
|
|
307
|
+
expect(sidebar.projectGroups[0]?.chats.find((chat) => chat.chatId === "chat-draining")?.canFork).toBeUndefined()
|
|
308
|
+
})
|
|
249
309
|
})
|
|
@@ -25,6 +25,18 @@ function getSidebarChatSortTimestamp(chat: ChatRecord) {
|
|
|
25
25
|
return chat.lastMessageAt ?? chat.createdAt
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function canForkChat(
|
|
29
|
+
chat: ChatRecord,
|
|
30
|
+
activeStatuses: Map<string, KannaStatus>,
|
|
31
|
+
drainingChatIds: Set<string>,
|
|
32
|
+
) {
|
|
33
|
+
if (!chat.provider) return false
|
|
34
|
+
if (!chat.sessionToken && !chat.pendingForkSessionToken) return false
|
|
35
|
+
if (activeStatuses.has(chat.id)) return false
|
|
36
|
+
if (drainingChatIds.has(chat.id)) return false
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
function getSidebarChatTimestamp(chat: Pick<SidebarChatRow, "lastMessageAt" | "_creationTime">) {
|
|
29
41
|
return chat.lastMessageAt ?? chat._creationTime
|
|
30
42
|
}
|
|
@@ -52,9 +64,11 @@ export function deriveSidebarData(
|
|
|
52
64
|
options?: {
|
|
53
65
|
nowMs?: number
|
|
54
66
|
sidebarProjectOrder?: string[]
|
|
67
|
+
drainingChatIds?: Set<string>
|
|
55
68
|
}
|
|
56
69
|
): SidebarData {
|
|
57
70
|
const nowMs = options?.nowMs ?? Date.now()
|
|
71
|
+
const drainingChatIds = options?.drainingChatIds ?? new Set<string>()
|
|
58
72
|
const chatsByProjectId = new Map<string, ChatRecord[]>()
|
|
59
73
|
for (const chat of state.chatsById.values()) {
|
|
60
74
|
if (chat.deletedAt) continue
|
|
@@ -94,6 +108,7 @@ export function deriveSidebarData(
|
|
|
94
108
|
provider: chat.provider,
|
|
95
109
|
lastMessageAt: chat.lastMessageAt,
|
|
96
110
|
hasAutomation: false,
|
|
111
|
+
canFork: canForkChat(chat, activeStatuses, drainingChatIds) || undefined,
|
|
97
112
|
}))
|
|
98
113
|
const { previewChats, olderChats } = getSidebarChatBuckets(chats, nowMs)
|
|
99
114
|
|
package/src/server/server.ts
CHANGED
|
@@ -60,6 +60,13 @@ export interface StartKannaServerOptions {
|
|
|
60
60
|
host?: string
|
|
61
61
|
password?: string | null
|
|
62
62
|
strictPort?: boolean
|
|
63
|
+
/**
|
|
64
|
+
* When true, the auth layer trusts X-Forwarded-Proto for CSRF origin
|
|
65
|
+
* checks, redirect URLs, and the Secure cookie flag. The hostname still
|
|
66
|
+
* comes from the request URL / Host header. Only enable when the server is
|
|
67
|
+
* reachable solely through a trusted reverse proxy such as cloudflared.
|
|
68
|
+
*/
|
|
69
|
+
trustProxy?: boolean
|
|
63
70
|
onMigrationProgress?: (message: string) => void
|
|
64
71
|
update?: {
|
|
65
72
|
version: string
|
|
@@ -72,7 +79,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
72
79
|
const port = options.port ?? 3210
|
|
73
80
|
const hostname = options.host ?? "127.0.0.1"
|
|
74
81
|
const strictPort = options.strictPort ?? false
|
|
75
|
-
const auth = options.password ? createAuthManager(options.password) : null
|
|
82
|
+
const auth = options.password ? createAuthManager(options.password, { trustProxy: options.trustProxy ?? false }) : null
|
|
76
83
|
const store = new EventStore()
|
|
77
84
|
const diffStore = new DiffStore(store.dataDir)
|
|
78
85
|
const machineDisplayName = getMachineDisplayName()
|
|
@@ -168,7 +175,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
168
175
|
if (auth) {
|
|
169
176
|
if (url.pathname === "/auth/login") {
|
|
170
177
|
if (req.method === "GET") {
|
|
171
|
-
return auth.
|
|
178
|
+
return auth.redirectToApp(req)
|
|
172
179
|
}
|
|
173
180
|
if (req.method === "POST") {
|
|
174
181
|
return auth.handleLogin(req, "/")
|
|
@@ -183,8 +190,8 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
183
190
|
if (!auth.isAuthenticated(req)) {
|
|
184
191
|
return new Response("Unauthorized", { status: 401 })
|
|
185
192
|
}
|
|
186
|
-
} else if (!auth.isAuthenticated(req)) {
|
|
187
|
-
return
|
|
193
|
+
} else if ((url.pathname === "/health" || url.pathname.startsWith("/api/")) && !auth.isAuthenticated(req)) {
|
|
194
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
|
188
195
|
}
|
|
189
196
|
}
|
|
190
197
|
|
|
@@ -17,6 +17,7 @@ function withSidebarGroupDefaults(group: {
|
|
|
17
17
|
localPath: string
|
|
18
18
|
provider: "claude" | "codex" | null
|
|
19
19
|
lastMessageAt?: number
|
|
20
|
+
canFork?: boolean
|
|
20
21
|
hasAutomation: boolean
|
|
21
22
|
}>
|
|
22
23
|
}) {
|
|
@@ -739,7 +740,10 @@ describe("ws-router", () => {
|
|
|
739
740
|
|
|
740
741
|
const router = createWsRouter({
|
|
741
742
|
store: store as never,
|
|
742
|
-
agent: {
|
|
743
|
+
agent: {
|
|
744
|
+
getActiveStatuses: () => new Map(),
|
|
745
|
+
getDrainingChatIds: () => new Set(),
|
|
746
|
+
} as never,
|
|
743
747
|
terminals: {
|
|
744
748
|
getSnapshot: () => null,
|
|
745
749
|
onEvent: () => () => {},
|
|
@@ -942,6 +946,135 @@ describe("ws-router", () => {
|
|
|
942
946
|
})
|
|
943
947
|
})
|
|
944
948
|
|
|
949
|
+
test("forks a chat through the agent and rebroadcasts the sidebar snapshot", async () => {
|
|
950
|
+
const state = createEmptyState()
|
|
951
|
+
state.projectsById.set("project-1", {
|
|
952
|
+
id: "project-1",
|
|
953
|
+
localPath: "/tmp/project",
|
|
954
|
+
title: "Project",
|
|
955
|
+
createdAt: 1,
|
|
956
|
+
updatedAt: 1,
|
|
957
|
+
})
|
|
958
|
+
state.chatsById.set("chat-1", {
|
|
959
|
+
id: "chat-1",
|
|
960
|
+
projectId: "project-1",
|
|
961
|
+
title: "Chat",
|
|
962
|
+
createdAt: 1,
|
|
963
|
+
updatedAt: 1,
|
|
964
|
+
unread: false,
|
|
965
|
+
provider: "claude",
|
|
966
|
+
planMode: false,
|
|
967
|
+
sessionToken: "session-1",
|
|
968
|
+
pendingForkSessionToken: null,
|
|
969
|
+
lastTurnOutcome: null,
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
const forkChatCalls: string[] = []
|
|
973
|
+
const router = createWsRouter({
|
|
974
|
+
store: { state } as never,
|
|
975
|
+
agent: {
|
|
976
|
+
getActiveStatuses: () => new Map(),
|
|
977
|
+
getDrainingChatIds: () => new Set(),
|
|
978
|
+
forkChat: async (chatId: string) => {
|
|
979
|
+
forkChatCalls.push(chatId)
|
|
980
|
+
state.chatsById.set("chat-fork-1", {
|
|
981
|
+
id: "chat-fork-1",
|
|
982
|
+
projectId: "project-1",
|
|
983
|
+
title: "Fork: Chat",
|
|
984
|
+
createdAt: 2,
|
|
985
|
+
updatedAt: 2,
|
|
986
|
+
unread: false,
|
|
987
|
+
provider: "claude",
|
|
988
|
+
planMode: false,
|
|
989
|
+
sessionToken: null,
|
|
990
|
+
pendingForkSessionToken: "session-1",
|
|
991
|
+
lastTurnOutcome: null,
|
|
992
|
+
})
|
|
993
|
+
return { chatId: "chat-fork-1" }
|
|
994
|
+
},
|
|
995
|
+
} as never,
|
|
996
|
+
terminals: {
|
|
997
|
+
getSnapshot: () => null,
|
|
998
|
+
onEvent: () => () => {},
|
|
999
|
+
} as never,
|
|
1000
|
+
keybindings: {
|
|
1001
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
1002
|
+
onChange: () => () => {},
|
|
1003
|
+
} as never,
|
|
1004
|
+
refreshDiscovery: async () => [],
|
|
1005
|
+
getDiscoveredProjects: () => [],
|
|
1006
|
+
machineDisplayName: "Local Machine",
|
|
1007
|
+
updateManager: null,
|
|
1008
|
+
})
|
|
1009
|
+
const ws = new FakeWebSocket()
|
|
1010
|
+
router.handleOpen(ws as never)
|
|
1011
|
+
|
|
1012
|
+
await router.handleMessage(
|
|
1013
|
+
ws as never,
|
|
1014
|
+
JSON.stringify({
|
|
1015
|
+
v: 1,
|
|
1016
|
+
type: "subscribe",
|
|
1017
|
+
id: "sidebar-sub-1",
|
|
1018
|
+
topic: { type: "sidebar" },
|
|
1019
|
+
})
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
await router.handleMessage(
|
|
1023
|
+
ws as never,
|
|
1024
|
+
JSON.stringify({
|
|
1025
|
+
v: 1,
|
|
1026
|
+
type: "command",
|
|
1027
|
+
id: "fork-1",
|
|
1028
|
+
command: { type: "chat.fork", chatId: "chat-1" },
|
|
1029
|
+
})
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
expect(forkChatCalls).toEqual(["chat-1"])
|
|
1033
|
+
expect(ws.sent.at(-2)).toEqual({
|
|
1034
|
+
v: PROTOCOL_VERSION,
|
|
1035
|
+
type: "ack",
|
|
1036
|
+
id: "fork-1",
|
|
1037
|
+
result: { chatId: "chat-fork-1" },
|
|
1038
|
+
})
|
|
1039
|
+
expect(ws.sent.at(-1)).toEqual({
|
|
1040
|
+
v: PROTOCOL_VERSION,
|
|
1041
|
+
type: "snapshot",
|
|
1042
|
+
id: "sidebar-sub-1",
|
|
1043
|
+
snapshot: {
|
|
1044
|
+
type: "sidebar",
|
|
1045
|
+
data: {
|
|
1046
|
+
projectGroups: [withSidebarGroupDefaults({
|
|
1047
|
+
groupKey: "project-1",
|
|
1048
|
+
localPath: "/tmp/project",
|
|
1049
|
+
chats: [{
|
|
1050
|
+
_id: "chat-fork-1",
|
|
1051
|
+
_creationTime: 2,
|
|
1052
|
+
chatId: "chat-fork-1",
|
|
1053
|
+
title: "Fork: Chat",
|
|
1054
|
+
status: "idle",
|
|
1055
|
+
unread: false,
|
|
1056
|
+
localPath: "/tmp/project",
|
|
1057
|
+
provider: "claude",
|
|
1058
|
+
canFork: true,
|
|
1059
|
+
hasAutomation: false,
|
|
1060
|
+
}, {
|
|
1061
|
+
_id: "chat-1",
|
|
1062
|
+
_creationTime: 1,
|
|
1063
|
+
chatId: "chat-1",
|
|
1064
|
+
title: "Chat",
|
|
1065
|
+
status: "idle",
|
|
1066
|
+
unread: false,
|
|
1067
|
+
localPath: "/tmp/project",
|
|
1068
|
+
provider: "claude",
|
|
1069
|
+
canFork: true,
|
|
1070
|
+
hasAutomation: false,
|
|
1071
|
+
}],
|
|
1072
|
+
})],
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
})
|
|
1076
|
+
})
|
|
1077
|
+
|
|
945
1078
|
test("prunes stale empty chats during explicit maintenance runs", async () => {
|
|
946
1079
|
const state = createEmptyState()
|
|
947
1080
|
state.projectsById.set("project-1", {
|
package/src/server/ws-router.ts
CHANGED
|
@@ -308,6 +308,7 @@ export function createWsRouter({
|
|
|
308
308
|
const startedAt = performance.now()
|
|
309
309
|
const data = deriveSidebarData(store.state, agent.getActiveStatuses(), {
|
|
310
310
|
sidebarProjectOrder: getSidebarProjectOrder(store),
|
|
311
|
+
drainingChatIds: agent.getDrainingChatIds(),
|
|
311
312
|
})
|
|
312
313
|
if (isSendToStartingProfilingEnabled()) {
|
|
313
314
|
const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
|
|
@@ -813,6 +814,12 @@ export function createWsRouter({
|
|
|
813
814
|
await broadcastChatAndSidebar(chat.id)
|
|
814
815
|
return
|
|
815
816
|
}
|
|
817
|
+
case "chat.fork": {
|
|
818
|
+
const result = await agent.forkChat(command.chatId)
|
|
819
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
|
|
820
|
+
await broadcastFilteredSnapshots({ includeSidebar: true })
|
|
821
|
+
return
|
|
822
|
+
}
|
|
816
823
|
case "chat.rename": {
|
|
817
824
|
await store.renameChat(command.chatId, command.title)
|
|
818
825
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
package/src/shared/protocol.ts
CHANGED
|
@@ -82,6 +82,7 @@ export type ClientCommand =
|
|
|
82
82
|
editor?: EditorOpenSettings
|
|
83
83
|
}
|
|
84
84
|
| { type: "chat.create"; projectId: string }
|
|
85
|
+
| { type: "chat.fork"; chatId: string }
|
|
85
86
|
| { type: "chat.rename"; chatId: string; title: string }
|
|
86
87
|
| { type: "chat.delete"; chatId: string }
|
|
87
88
|
| { type: "chat.setDraftProtection"; chatIds: string[] }
|