kanna-code 0.32.3 → 0.33.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.
@@ -26,9 +26,10 @@ describe("read models", () => {
26
26
  lastTurnOutcome: null,
27
27
  })
28
28
 
29
- const sidebar = deriveSidebarData(state, new Map(), 1_000_000)
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)
@@ -198,9 +199,7 @@ describe("read models", () => {
198
199
  createdAt: 3,
199
200
  updatedAt: 15,
200
201
  })
201
- state.sidebarProjectOrder = ["project-1"]
202
-
203
- const sidebar = deriveSidebarData(state, new Map())
202
+ const sidebar = deriveSidebarData(state, new Map(), { sidebarProjectOrder: ["project-1"] })
204
203
 
205
204
  expect(sidebar.projectGroups.map((group) => group.groupKey)).toEqual(["project-1", "project-2", "project-3"])
206
205
  })
@@ -242,10 +241,69 @@ describe("read models", () => {
242
241
  lastTurnOutcome: null,
243
242
  })
244
243
 
245
- const sidebar = deriveSidebarData(state, new Map(), 1_000_000)
244
+ const sidebar = deriveSidebarData(state, new Map(), { nowMs: 1_000_000 })
246
245
 
247
246
  expect(sidebar.projectGroups[0]?.previewChats.map((chat) => chat.chatId)).toEqual(["chat-1"])
248
247
  expect(sidebar.projectGroups[0]?.olderChats.map((chat) => chat.chatId)).toEqual(["chat-2"])
249
248
  expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
250
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
+ })
251
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
  }
@@ -49,8 +61,14 @@ function getSidebarChatBuckets(chats: SidebarChatRow[], nowMs: number) {
49
61
  export function deriveSidebarData(
50
62
  state: StoreState,
51
63
  activeStatuses: Map<string, KannaStatus>,
52
- nowMs = Date.now()
64
+ options?: {
65
+ nowMs?: number
66
+ sidebarProjectOrder?: string[]
67
+ drainingChatIds?: Set<string>
68
+ }
53
69
  ): SidebarData {
70
+ const nowMs = options?.nowMs ?? Date.now()
71
+ const drainingChatIds = options?.drainingChatIds ?? new Set<string>()
54
72
  const chatsByProjectId = new Map<string, ChatRecord[]>()
55
73
  for (const chat of state.chatsById.values()) {
56
74
  if (chat.deletedAt) continue
@@ -67,7 +85,7 @@ export function deriveSidebarData(
67
85
  const unorderedProjects = allProjects
68
86
  .sort((a, b) => b.updatedAt - a.updatedAt)
69
87
  const projectById = new Map(unorderedProjects.map((project) => [project.id, project]))
70
- const orderedProjects = state.sidebarProjectOrder
88
+ const orderedProjects = (options?.sidebarProjectOrder ?? [])
71
89
  .map((projectId) => projectById.get(projectId))
72
90
  .filter((project): project is NonNullable<typeof project> => Boolean(project))
73
91
  const orderedProjectIds = new Set(orderedProjects.map((project) => project.id))
@@ -90,6 +108,7 @@ export function deriveSidebarData(
90
108
  provider: chat.provider,
91
109
  lastMessageAt: chat.lastMessageAt,
92
110
  hasAutomation: false,
111
+ canFork: canForkChat(chat, activeStatuses, drainingChatIds) || undefined,
93
112
  }))
94
113
  const { previewChats, olderChats } = getSidebarChatBuckets(chats, nowMs)
95
114
 
@@ -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()
@@ -63,42 +63,10 @@ function createNamedTunnel(token: string, localUrl: string) {
63
63
  return tunnel
64
64
  }
65
65
 
66
- async function awaitQuickTunnelUrl(tunnel: ShareTunnelProcess) {
67
- return await new Promise<string>((resolve, reject) => {
68
- let settled = false
69
-
70
- const cleanup = () => {
71
- tunnel.off("url", handleUrl)
72
- tunnel.off("error", handleError)
73
- tunnel.off("exit", handleExit)
74
- }
75
-
76
- const settle = (callback: () => void) => {
77
- if (settled) return
78
- settled = true
79
- cleanup()
80
- callback()
81
- }
82
-
83
- const handleUrl = (url: string) => {
84
- settle(() => resolve(normalizePublicUrl(url)))
85
- }
86
-
87
- const handleError = (error: Error) => {
88
- settle(() => reject(error))
89
- }
90
-
91
- const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
92
- settle(() => reject(new Error(`Cloudflare tunnel exited before a public URL was ready (code: ${String(code)}, signal: ${String(signal)})`)))
93
- }
94
-
95
- tunnel.once("url", handleUrl)
96
- tunnel.once("error", handleError)
97
- tunnel.once("exit", handleExit)
98
- })
99
- }
100
-
101
- async function awaitNamedTunnelReady(tunnel: ShareTunnelProcess) {
66
+ async function awaitTunnelReady(
67
+ tunnel: ShareTunnelProcess,
68
+ { expectUrl }: { expectUrl: boolean },
69
+ ) {
102
70
  return await new Promise<string | null>((resolve, reject) => {
103
71
  let settled = false
104
72
 
@@ -121,6 +89,7 @@ async function awaitNamedTunnelReady(tunnel: ShareTunnelProcess) {
121
89
  }
122
90
 
123
91
  const handleConnected = () => {
92
+ if (expectUrl) return
124
93
  settle(() => resolve(null))
125
94
  }
126
95
 
@@ -129,11 +98,14 @@ async function awaitNamedTunnelReady(tunnel: ShareTunnelProcess) {
129
98
  }
130
99
 
131
100
  const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
132
- settle(() => reject(new Error(`Cloudflare tunnel exited before it was ready (code: ${String(code)}, signal: ${String(signal)})`)))
101
+ const readyState = expectUrl ? "a public URL was ready" : "it was ready"
102
+ settle(() => reject(new Error(`Cloudflare tunnel exited before ${readyState} (code: ${String(code)}, signal: ${String(signal)})`)))
133
103
  }
134
104
 
135
105
  tunnel.once("url", handleUrl)
136
- tunnel.once("connected", handleConnected)
106
+ if (!expectUrl) {
107
+ tunnel.once("connected", handleConnected)
108
+ }
137
109
  tunnel.once("error", handleError)
138
110
  tunnel.once("exit", handleExit)
139
111
  })
@@ -145,12 +117,11 @@ export async function startShareTunnel(
145
117
  deps: ShareTunnelDeps = {},
146
118
  ): Promise<StartedShareTunnel> {
147
119
  await ensureCloudflaredInstalled(deps)
148
- const tunnel = isTokenShareMode(shareMode)
120
+ const namedTunnel = isTokenShareMode(shareMode)
121
+ const tunnel = namedTunnel
149
122
  ? (deps.createNamedTunnel ?? createNamedTunnel)(shareMode.token, localUrl)
150
123
  : (deps.createQuickTunnel ?? ((url) => Tunnel.quick(url)))(localUrl)
151
- const publicUrl = isTokenShareMode(shareMode)
152
- ? await awaitNamedTunnelReady(tunnel)
153
- : await awaitQuickTunnelUrl(tunnel)
124
+ const publicUrl = await awaitTunnelReady(tunnel, { expectUrl: !namedTunnel })
154
125
 
155
126
  return {
156
127
  publicUrl,
@@ -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: { getActiveStatuses: () => new Map() } as never,
743
+ agent: {
744
+ getActiveStatuses: () => new Map(),
745
+ getDrainingChatIds: () => new Set(),
746
+ } as never,
743
747
  terminals: {
744
748
  getSnapshot: () => null,
745
749
  onEvent: () => () => {},
@@ -863,12 +867,16 @@ describe("ws-router", () => {
863
867
  })
864
868
 
865
869
  const setSidebarProjectOrderCalls: string[][] = []
870
+ let sidebarProjectOrder: string[] = []
866
871
  const router = createWsRouter({
867
872
  store: {
868
873
  state,
874
+ getSidebarProjectOrder() {
875
+ return [...sidebarProjectOrder]
876
+ },
869
877
  async setSidebarProjectOrder(projectIds: string[]) {
870
878
  setSidebarProjectOrderCalls.push(projectIds)
871
- state.sidebarProjectOrder = [...projectIds]
879
+ sidebarProjectOrder = [...projectIds]
872
880
  },
873
881
  } as never,
874
882
  agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
@@ -938,6 +946,135 @@ describe("ws-router", () => {
938
946
  })
939
947
  })
940
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
+
941
1078
  test("prunes stale empty chats during explicit maintenance runs", async () => {
942
1079
  const state = createEmptyState()
943
1080
  state.projectsById.set("project-1", {
@@ -126,6 +126,12 @@ interface SnapshotComputationCache {
126
126
  }
127
127
  }
128
128
 
129
+ function getSidebarProjectOrder(store: EventStore) {
130
+ return typeof store.getSidebarProjectOrder === "function"
131
+ ? store.getSidebarProjectOrder()
132
+ : []
133
+ }
134
+
129
135
  function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
130
136
  const payload = JSON.stringify(message)
131
137
  ws.send(payload)
@@ -300,7 +306,10 @@ export function createWsRouter({
300
306
  }
301
307
 
302
308
  const startedAt = performance.now()
303
- const data = deriveSidebarData(store.state, agent.getActiveStatuses())
309
+ const data = deriveSidebarData(store.state, agent.getActiveStatuses(), {
310
+ sidebarProjectOrder: getSidebarProjectOrder(store),
311
+ drainingChatIds: agent.getDrainingChatIds(),
312
+ })
304
313
  if (isSendToStartingProfilingEnabled()) {
305
314
  const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
306
315
  console.log("[kanna/send->starting][server]", JSON.stringify({
@@ -805,6 +814,12 @@ export function createWsRouter({
805
814
  await broadcastChatAndSidebar(chat.id)
806
815
  return
807
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
+ }
808
823
  case "chat.rename": {
809
824
  await store.renameChat(command.chatId, command.title)
810
825
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
@@ -1,5 +1,5 @@
1
1
  import type { ShareMode } from "./share"
2
- import { isShareEnabled } from "./share"
2
+ import { assertNoHostOverride, getShareCliFlag, isShareEnabled } from "./share"
3
3
 
4
4
  export const DEFAULT_DEV_CLIENT_PORT = 5174
5
5
 
@@ -89,14 +89,12 @@ export function parseDevArgs(args: string[], localHostname: string): DevArgResol
89
89
  for (let index = 0; index < args.length; index += 1) {
90
90
  const arg = args[index]
91
91
  if (arg === "--share") {
92
- if (sawHost) throw new Error("--share cannot be used with --host")
93
- if (sawRemote) throw new Error("--share cannot be used with --remote")
92
+ assertNoHostOverride("--share", sawHost, sawRemote)
94
93
  share = "quick"
95
94
  continue
96
95
  }
97
96
  if (arg === "--cloudflared") {
98
- if (sawHost) throw new Error("--cloudflared cannot be used with --host")
99
- if (sawRemote) throw new Error("--cloudflared cannot be used with --remote")
97
+ assertNoHostOverride("--cloudflared", sawHost, sawRemote)
100
98
  const next = args[index + 1]
101
99
  if (!next || next.startsWith("-")) throw new Error("Missing value for --cloudflared")
102
100
  share = { kind: "token", token: next }
@@ -105,7 +103,7 @@ export function parseDevArgs(args: string[], localHostname: string): DevArgResol
105
103
  }
106
104
  if (arg === "--remote") {
107
105
  if (isShareEnabled(share)) {
108
- throw new Error(typeof share === "string" ? "--share cannot be used with --remote" : "--cloudflared cannot be used with --remote")
106
+ throw new Error(`${getShareCliFlag(share)} cannot be used with --remote`)
109
107
  }
110
108
  sawRemote = true
111
109
  backendTargetHost = "127.0.0.1"
@@ -117,7 +115,7 @@ export function parseDevArgs(args: string[], localHostname: string): DevArgResol
117
115
  const next = args[index + 1]
118
116
  if (!next || next.startsWith("-")) continue
119
117
  if (isShareEnabled(share)) {
120
- throw new Error(typeof share === "string" ? "--share cannot be used with --host" : "--cloudflared cannot be used with --host")
118
+ throw new Error(`${getShareCliFlag(share)} cannot be used with --host`)
121
119
  }
122
120
  sawHost = true
123
121
  hosts.add(next)
@@ -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[] }
@@ -3,6 +3,8 @@ export type ShareMode = false | "quick" | {
3
3
  token: string
4
4
  }
5
5
 
6
+ export type ShareCliFlag = "--share" | "--cloudflared"
7
+
6
8
  export function isShareEnabled(share: ShareMode): share is Exclude<ShareMode, false> {
7
9
  return share !== false
8
10
  }
@@ -10,3 +12,16 @@ export function isShareEnabled(share: ShareMode): share is Exclude<ShareMode, fa
10
12
  export function isTokenShareMode(share: ShareMode): share is { kind: "token"; token: string } {
11
13
  return typeof share === "object" && share !== null && share.kind === "token"
12
14
  }
15
+
16
+ export function getShareCliFlag(share: Exclude<ShareMode, false>): ShareCliFlag {
17
+ return isTokenShareMode(share) ? "--cloudflared" : "--share"
18
+ }
19
+
20
+ export function assertNoHostOverride(shareFlag: ShareCliFlag, sawHost: boolean, sawRemote: boolean) {
21
+ if (sawHost) {
22
+ throw new Error(`${shareFlag} cannot be used with --host`)
23
+ }
24
+ if (sawRemote) {
25
+ throw new Error(`${shareFlag} cannot be used with --remote`)
26
+ }
27
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ normalizeClaudeModelId,
4
+ normalizeCodexModelId,
5
+ supportsClaudeMaxReasoningEffort,
6
+ } from "./types"
7
+
8
+ describe("shared model normalization", () => {
9
+ test("normalizes Claude aliases via the provider catalog", () => {
10
+ expect(normalizeClaudeModelId("opus")).toBe("claude-opus-4-7")
11
+ expect(normalizeClaudeModelId("sonnet")).toBe("claude-sonnet-4-6")
12
+ expect(normalizeClaudeModelId("haiku")).toBe("claude-haiku-4-5-20251001")
13
+ })
14
+
15
+ test("normalizes legacy Codex aliases via the provider catalog", () => {
16
+ expect(normalizeCodexModelId("gpt-5-codex")).toBe("gpt-5.3-codex")
17
+ })
18
+
19
+ test("uses declarative metadata for Claude max-effort support", () => {
20
+ expect(supportsClaudeMaxReasoningEffort("claude-opus-4-7")).toBe(true)
21
+ expect(supportsClaudeMaxReasoningEffort("opus")).toBe(true)
22
+ expect(supportsClaudeMaxReasoningEffort("claude-sonnet-4-6")).toBe(false)
23
+ })
24
+ })