@cuongtran001/kanna 1.2.0 → 1.3.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/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
- package/dist/client/assets/channel-BEYTrUAm.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
- package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
- package/dist/client/assets/index-CLsOWXff.css +32 -0
- package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
- package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
- package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
- package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent-coordinator-types.ts +158 -0
- package/src/server/agent-coordinator.ts +919 -0
- package/src/server/agent-deps-builders.ts +425 -0
- package/src/server/agent.oauth-rotation.test.ts +101 -0
- package/src/server/agent.test.ts +24 -8
- package/src/server/agent.ts +35 -4963
- package/src/server/auto-continue/read-model.test.ts +2 -2
- package/src/server/auto-continue/read-model.ts +4 -1
- package/src/server/claude-autocontinue-commands.test.ts +494 -0
- package/src/server/claude-autocontinue-commands.ts +256 -0
- package/src/server/claude-cancel-handler.test.ts +472 -0
- package/src/server/claude-cancel-handler.ts +241 -0
- package/src/server/claude-chat-management.test.ts +395 -0
- package/src/server/claude-chat-management.ts +251 -0
- package/src/server/claude-harness-stream.test.ts +273 -0
- package/src/server/claude-harness-stream.ts +158 -0
- package/src/server/claude-loop-orch-commands.test.ts +579 -0
- package/src/server/claude-loop-orch-commands.ts +466 -0
- package/src/server/claude-message-normalizer.test.ts +213 -0
- package/src/server/claude-message-normalizer.ts +436 -0
- package/src/server/claude-prompt-helpers.test.ts +248 -0
- package/src/server/claude-prompt-helpers.ts +139 -0
- package/src/server/claude-sdk-queue.test.ts +169 -0
- package/src/server/claude-sdk-queue.ts +82 -0
- package/src/server/claude-send-command.test.ts +414 -0
- package/src/server/claude-send-command.ts +420 -0
- package/src/server/claude-session-config-helpers.test.ts +279 -0
- package/src/server/claude-session-config-helpers.ts +151 -0
- package/src/server/claude-session-config.test.ts +250 -0
- package/src/server/claude-session-config.ts +159 -0
- package/src/server/claude-session-error-handler.test.ts +498 -0
- package/src/server/claude-session-error-handler.ts +361 -0
- package/src/server/claude-session-lifecycle.test.ts +669 -0
- package/src/server/claude-session-lifecycle.ts +283 -0
- package/src/server/claude-session-rebuild.test.ts +224 -0
- package/src/server/claude-session-rebuild.ts +101 -0
- package/src/server/claude-session-runner.test.ts +523 -0
- package/src/server/claude-session-runner.ts +521 -0
- package/src/server/claude-session-spawner.test.ts +401 -0
- package/src/server/claude-session-spawner.ts +401 -0
- package/src/server/claude-session-start.test.ts +205 -0
- package/src/server/claude-session-start.ts +277 -0
- package/src/server/claude-session-state-queries.test.ts +344 -0
- package/src/server/claude-session-state-queries.ts +160 -0
- package/src/server/claude-session-state.ts +104 -0
- package/src/server/claude-slash-commands.test.ts +277 -0
- package/src/server/claude-slash-commands.ts +234 -0
- package/src/server/claude-spawn-helpers.test.ts +221 -0
- package/src/server/claude-spawn-helpers.ts +179 -0
- package/src/server/claude-steer-log.test.ts +243 -0
- package/src/server/claude-steer-log.ts +58 -0
- package/src/server/claude-subagent-tool-response.test.ts +213 -0
- package/src/server/claude-subagent-tool-response.ts +165 -0
- package/src/server/claude-subagent-wiring.test.ts +263 -0
- package/src/server/claude-subagent-wiring.ts +324 -0
- package/src/server/claude-tool-respond.test.ts +321 -0
- package/src/server/claude-tool-respond.ts +142 -0
- package/src/server/claude-turn-runner.test.ts +310 -0
- package/src/server/claude-turn-runner.ts +228 -0
- package/src/server/claude-turn-starter.test.ts +266 -0
- package/src/server/claude-turn-starter.ts +566 -0
- package/src/server/claude-usage-math.test.ts +169 -0
- package/src/server/claude-usage-math.ts +132 -0
- package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
- package/src/server/diff-store-branch-ops.adapter.ts +602 -0
- package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
- package/src/server/diff-store-commit-ops.adapter.ts +258 -0
- package/src/server/diff-store-errors.ts +85 -0
- package/src/server/diff-store-file-ops.adapter.ts +212 -0
- package/src/server/diff-store-git-branch.adapter.ts +686 -0
- package/src/server/diff-store-io.adapter.ts +27 -0
- package/src/server/diff-store-parse.test.ts +212 -0
- package/src/server/diff-store-parse.ts +122 -0
- package/src/server/diff-store-state.ts +82 -0
- package/src/server/diff-store.ts +76 -1854
- package/src/server/event-store-apply.test.ts +280 -0
- package/src/server/event-store-apply.ts +132 -0
- package/src/server/event-store-chat-lifecycle.test.ts +408 -0
- package/src/server/event-store-chat-lifecycle.ts +441 -0
- package/src/server/event-store-entity-write.ts +356 -0
- package/src/server/event-store-helpers.test.ts +230 -0
- package/src/server/event-store-helpers.ts +235 -0
- package/src/server/event-store-init.ts +276 -0
- package/src/server/event-store-messages.adapter.test.ts +365 -0
- package/src/server/event-store-messages.adapter.ts +213 -0
- package/src/server/event-store-orch-subscription.ts +124 -0
- package/src/server/event-store-orch.test.ts +462 -0
- package/src/server/event-store-orch.ts +312 -0
- package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
- package/src/server/event-store-peripheral-events.adapter.ts +105 -0
- package/src/server/event-store-snapshot.test.ts +597 -0
- package/src/server/event-store-snapshot.ts +658 -0
- package/src/server/event-store-subagent.test.ts +408 -0
- package/src/server/event-store-subagent.ts +198 -0
- package/src/server/event-store-tool-requests.test.ts +210 -0
- package/src/server/event-store-tool-requests.ts +131 -0
- package/src/server/event-store-transcript-write.adapter.ts +285 -0
- package/src/server/event-store-write-ops.test.ts +554 -0
- package/src/server/event-store-write-ops.ts +529 -0
- package/src/server/event-store.test.ts +35 -0
- package/src/server/event-store.ts +251 -2284
- package/src/server/events.ts +3 -0
- package/src/server/harness-types.ts +15 -1
- package/src/server/oauth-errors.ts +19 -0
- package/src/server/read-models.ts +26 -1
- package/src/server/subagent-orchestrator.ts +3 -0
- package/src/server/ws-router-agent-ctrl.test.ts +187 -0
- package/src/server/ws-router-agent-ctrl.ts +137 -0
- package/src/server/ws-router-broadcast.ts +488 -0
- package/src/server/ws-router-chat.test.ts +461 -0
- package/src/server/ws-router-chat.ts +279 -0
- package/src/server/ws-router-defaults.test.ts +283 -0
- package/src/server/ws-router-defaults.ts +434 -0
- package/src/server/ws-router-diff.test.ts +167 -0
- package/src/server/ws-router-diff.ts +250 -0
- package/src/server/ws-router-envelope.ts +329 -0
- package/src/server/ws-router-misc.test.ts +422 -0
- package/src/server/ws-router-misc.ts +265 -0
- package/src/server/ws-router-orch.test.ts +190 -0
- package/src/server/ws-router-orch.ts +93 -0
- package/src/server/ws-router-project.test.ts +403 -0
- package/src/server/ws-router-project.ts +256 -0
- package/src/server/ws-router-push.test.ts +193 -0
- package/src/server/ws-router-push.ts +120 -0
- package/src/server/ws-router-settings.test.ts +173 -0
- package/src/server/ws-router-settings.ts +416 -0
- package/src/server/ws-router-skills.test.ts +194 -0
- package/src/server/ws-router-skills.ts +204 -0
- package/src/server/ws-router-utils.ts +224 -0
- package/src/server/ws-router.ts +339 -2254
- package/src/shared/app-settings-types.ts +365 -0
- package/src/shared/core-types.ts +27 -0
- package/src/shared/git-diff-types.ts +165 -0
- package/src/shared/loop-progress.test.ts +106 -0
- package/src/shared/loop-progress.ts +88 -0
- package/src/shared/mcp-types.ts +105 -0
- package/src/shared/provider-model-types.ts +437 -0
- package/src/shared/subagent-types.ts +198 -0
- package/src/shared/tool-call-types.ts +314 -0
- package/src/shared/transcript-types.ts +270 -0
- package/src/shared/types.ts +27 -1668
- package/dist/client/assets/channel-BMkhjiLI.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
- package/dist/client/assets/index-H6C-f2Vp.css +0 -32
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
maxClaudeContextWindowFromModelUsage,
|
|
4
|
+
normalizeClaudeUsageSnapshot,
|
|
5
|
+
parseConfiguredContextWindowFromModelId,
|
|
6
|
+
resolveFinalTurnUsage,
|
|
7
|
+
} from "./claude-usage-math"
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// normalizeClaudeUsageSnapshot
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
describe("normalizeClaudeUsageSnapshot", () => {
|
|
14
|
+
test("returns null for null input", () => {
|
|
15
|
+
expect(normalizeClaudeUsageSnapshot(null)).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("returns null for empty object (zero usedTokens)", () => {
|
|
19
|
+
expect(normalizeClaudeUsageSnapshot({})).toBeNull()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("returns null for non-record input", () => {
|
|
23
|
+
expect(normalizeClaudeUsageSnapshot("string")).toBeNull()
|
|
24
|
+
expect(normalizeClaudeUsageSnapshot(42)).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("normalizes basic input/output tokens", () => {
|
|
28
|
+
const result = normalizeClaudeUsageSnapshot({
|
|
29
|
+
input_tokens: 100,
|
|
30
|
+
output_tokens: 50,
|
|
31
|
+
})
|
|
32
|
+
expect(result).not.toBeNull()
|
|
33
|
+
expect(result!.usedTokens).toBe(150)
|
|
34
|
+
expect(result!.inputTokens).toBe(100)
|
|
35
|
+
expect(result!.outputTokens).toBe(50)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("accepts camelCase field names", () => {
|
|
39
|
+
const result = normalizeClaudeUsageSnapshot({
|
|
40
|
+
inputTokens: 200,
|
|
41
|
+
outputTokens: 80,
|
|
42
|
+
})
|
|
43
|
+
expect(result).not.toBeNull()
|
|
44
|
+
expect(result!.usedTokens).toBe(280)
|
|
45
|
+
expect(result!.inputTokens).toBe(200)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("includes cachedInputTokens when cache_read_input_tokens > 0", () => {
|
|
49
|
+
const result = normalizeClaudeUsageSnapshot({
|
|
50
|
+
input_tokens: 100,
|
|
51
|
+
output_tokens: 20,
|
|
52
|
+
cache_read_input_tokens: 30,
|
|
53
|
+
})
|
|
54
|
+
expect(result).not.toBeNull()
|
|
55
|
+
// usedTokens includes cached tokens (which are part of total input)
|
|
56
|
+
expect(result!.cachedInputTokens).toBe(30)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("passes maxTokens through when provided and positive", () => {
|
|
60
|
+
const result = normalizeClaudeUsageSnapshot(
|
|
61
|
+
{ input_tokens: 500, output_tokens: 100 },
|
|
62
|
+
200_000,
|
|
63
|
+
)
|
|
64
|
+
expect(result).not.toBeNull()
|
|
65
|
+
expect(result!.maxTokens).toBe(200_000)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("omits maxTokens when not provided", () => {
|
|
69
|
+
const result = normalizeClaudeUsageSnapshot({ input_tokens: 500, output_tokens: 100 })
|
|
70
|
+
expect(result).not.toBeNull()
|
|
71
|
+
expect(result!.maxTokens).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("includes reasoningOutputTokens when provided", () => {
|
|
75
|
+
const result = normalizeClaudeUsageSnapshot({
|
|
76
|
+
input_tokens: 100,
|
|
77
|
+
output_tokens: 50,
|
|
78
|
+
reasoning_output_tokens: 20,
|
|
79
|
+
})
|
|
80
|
+
expect(result).not.toBeNull()
|
|
81
|
+
expect(result!.reasoningOutputTokens).toBe(20)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// resolveFinalTurnUsage
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
describe("resolveFinalTurnUsage", () => {
|
|
90
|
+
test("returns null when latestUsageSnapshot is null", () => {
|
|
91
|
+
expect(resolveFinalTurnUsage(null, null, undefined)).toBeNull()
|
|
92
|
+
expect(resolveFinalTurnUsage(null, normalizeClaudeUsageSnapshot({ input_tokens: 100, output_tokens: 10 }), 200_000)).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("propagates maxTokens from lastKnownContextWindow", () => {
|
|
96
|
+
const live = normalizeClaudeUsageSnapshot({ input_tokens: 100, output_tokens: 50 })!
|
|
97
|
+
const result = resolveFinalTurnUsage(live, null, 200_000)
|
|
98
|
+
expect(result!.maxTokens).toBe(200_000)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("enriches totalProcessedTokens when accumulated > live usedTokens", () => {
|
|
102
|
+
const live = normalizeClaudeUsageSnapshot({ input_tokens: 100, output_tokens: 50 })!
|
|
103
|
+
const accumulated = normalizeClaudeUsageSnapshot({ input_tokens: 500, output_tokens: 50 })!
|
|
104
|
+
expect(accumulated.usedTokens).toBeGreaterThan(live.usedTokens)
|
|
105
|
+
|
|
106
|
+
const result = resolveFinalTurnUsage(live, accumulated, undefined)
|
|
107
|
+
expect(result!.totalProcessedTokens).toBe(accumulated.usedTokens)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test("does NOT enrich totalProcessedTokens when accumulated <= live usedTokens", () => {
|
|
111
|
+
const live = normalizeClaudeUsageSnapshot({ input_tokens: 500, output_tokens: 50 })!
|
|
112
|
+
const accumulated = normalizeClaudeUsageSnapshot({ input_tokens: 100, output_tokens: 20 })!
|
|
113
|
+
expect(accumulated.usedTokens).toBeLessThanOrEqual(live.usedTokens)
|
|
114
|
+
|
|
115
|
+
const result = resolveFinalTurnUsage(live, accumulated, undefined)
|
|
116
|
+
expect(result!.totalProcessedTokens).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// maxClaudeContextWindowFromModelUsage
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe("maxClaudeContextWindowFromModelUsage", () => {
|
|
125
|
+
test("returns undefined for null", () => {
|
|
126
|
+
expect(maxClaudeContextWindowFromModelUsage(null)).toBeUndefined()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test("returns undefined for non-record input", () => {
|
|
130
|
+
expect(maxClaudeContextWindowFromModelUsage("string")).toBeUndefined()
|
|
131
|
+
expect(maxClaudeContextWindowFromModelUsage(42)).toBeUndefined()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("returns undefined for empty object", () => {
|
|
135
|
+
expect(maxClaudeContextWindowFromModelUsage({})).toBeUndefined()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test("returns max contextWindow across multiple models", () => {
|
|
139
|
+
const modelUsage = {
|
|
140
|
+
"claude-opus": { contextWindow: 200_000, inputTokens: 100 },
|
|
141
|
+
"claude-sonnet": { context_window: 1_000_000, inputTokens: 200 },
|
|
142
|
+
}
|
|
143
|
+
expect(maxClaudeContextWindowFromModelUsage(modelUsage)).toBe(1_000_000)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("accepts context_window (snake_case) field name", () => {
|
|
147
|
+
const modelUsage = {
|
|
148
|
+
model: { context_window: 150_000 },
|
|
149
|
+
}
|
|
150
|
+
expect(maxClaudeContextWindowFromModelUsage(modelUsage)).toBe(150_000)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// parseConfiguredContextWindowFromModelId
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe("parseConfiguredContextWindowFromModelId", () => {
|
|
159
|
+
test("returns 1_000_000 for model ids ending with [1m]", () => {
|
|
160
|
+
expect(parseConfiguredContextWindowFromModelId("claude-opus-4-6[1m]")).toBe(1_000_000)
|
|
161
|
+
expect(parseConfiguredContextWindowFromModelId("claude-sonnet-4-7[1m]")).toBe(1_000_000)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test("returns undefined for model ids without [1m] suffix", () => {
|
|
165
|
+
expect(parseConfiguredContextWindowFromModelId("claude-opus-4-6")).toBeUndefined()
|
|
166
|
+
expect(parseConfiguredContextWindowFromModelId("claude-sonnet-4-7")).toBeUndefined()
|
|
167
|
+
expect(parseConfiguredContextWindowFromModelId("")).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-usage-math.ts
|
|
3
|
+
*
|
|
4
|
+
* Pure context-window usage math: normalises raw SDK token-count shapes into
|
|
5
|
+
* typed ContextWindowUsageSnapshot values. No IO, no side effects.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from agent.ts — see decompose-large-files loop.
|
|
8
|
+
*/
|
|
9
|
+
import type { ContextWindowUsageSnapshot } from "../shared/types"
|
|
10
|
+
import { isRecord } from "../shared/errors"
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Private narrowing helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
function asRecord<T>(value: T): Record<string, unknown> | null {
|
|
17
|
+
return isRecord(value) ? value : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function asNumber<T>(value: T): number | undefined {
|
|
21
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Public exports
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalise the raw `usage` object from a Claude SDK assistant/result message
|
|
30
|
+
* into a typed ContextWindowUsageSnapshot, returning null when the value is
|
|
31
|
+
* empty or contains no positive token counts.
|
|
32
|
+
*
|
|
33
|
+
* Accepts both camelCase (SDK internal) and snake_case (JSON-wire) field names.
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeClaudeUsageSnapshot<T>(
|
|
36
|
+
value: T,
|
|
37
|
+
maxTokens?: number,
|
|
38
|
+
): ContextWindowUsageSnapshot | null {
|
|
39
|
+
const usage = asRecord(value)
|
|
40
|
+
if (!usage) return null
|
|
41
|
+
|
|
42
|
+
const directInputTokens = asNumber(usage.input_tokens) ?? asNumber(usage.inputTokens) ?? 0
|
|
43
|
+
const cacheCreationInputTokens =
|
|
44
|
+
asNumber(usage.cache_creation_input_tokens) ?? asNumber(usage.cacheCreationInputTokens) ?? 0
|
|
45
|
+
const cacheReadInputTokens =
|
|
46
|
+
asNumber(usage.cache_read_input_tokens) ?? asNumber(usage.cacheReadInputTokens) ?? 0
|
|
47
|
+
const outputTokens = asNumber(usage.output_tokens) ?? asNumber(usage.outputTokens) ?? 0
|
|
48
|
+
const reasoningOutputTokens =
|
|
49
|
+
asNumber(usage.reasoning_output_tokens) ?? asNumber(usage.reasoningOutputTokens)
|
|
50
|
+
const toolUses = asNumber(usage.tool_uses) ?? asNumber(usage.toolUses)
|
|
51
|
+
const durationMs = asNumber(usage.duration_ms) ?? asNumber(usage.durationMs)
|
|
52
|
+
|
|
53
|
+
const inputTokens = directInputTokens + cacheCreationInputTokens + cacheReadInputTokens
|
|
54
|
+
const usedTokens = inputTokens + outputTokens
|
|
55
|
+
if (usedTokens <= 0) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
usedTokens,
|
|
61
|
+
inputTokens,
|
|
62
|
+
...(cacheReadInputTokens > 0 ? { cachedInputTokens: cacheReadInputTokens } : {}),
|
|
63
|
+
...(outputTokens > 0 ? { outputTokens } : {}),
|
|
64
|
+
...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
|
|
65
|
+
lastUsedTokens: usedTokens,
|
|
66
|
+
lastInputTokens: inputTokens,
|
|
67
|
+
...(cacheReadInputTokens > 0 ? { lastCachedInputTokens: cacheReadInputTokens } : {}),
|
|
68
|
+
...(outputTokens > 0 ? { lastOutputTokens: outputTokens } : {}),
|
|
69
|
+
...(reasoningOutputTokens !== undefined ? { lastReasoningOutputTokens: reasoningOutputTokens } : {}),
|
|
70
|
+
...(toolUses !== undefined ? { toolUses } : {}),
|
|
71
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
72
|
+
...(typeof maxTokens === "number" && maxTokens > 0 ? { maxTokens } : {}),
|
|
73
|
+
compactsAutomatically: false,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Resolve the single `context_window_updated` snapshot emitted at end of a
|
|
78
|
+
// turn. `latestUsageSnapshot` is the last per-`assistant`-message usage — a
|
|
79
|
+
// single-request view, the real live context size. `accumulatedUsage` is
|
|
80
|
+
// derived from SDK `result.usage`, which is CUMULATIVE: it re-counts
|
|
81
|
+
// `cache_read_input_tokens` on every tool round-trip, so its `usedTokens`
|
|
82
|
+
// balloons to millions on long turns.
|
|
83
|
+
//
|
|
84
|
+
// The cumulative figure must never become `usedTokens` — proactive-compact
|
|
85
|
+
// reads `usedTokens` and would trip far below the real threshold, then the
|
|
86
|
+
// no-assistant-usage compact turn would re-inflate and force a second
|
|
87
|
+
// compact (the double-compact bug). So cumulative only ever enriches
|
|
88
|
+
// `totalProcessedTokens`. When no per-assistant snapshot exists (compact /
|
|
89
|
+
// system turns), return null: the caller skips emission and proactive-compact
|
|
90
|
+
// falls back to the prior live snapshot (or a compact_boundary → no compact).
|
|
91
|
+
export function resolveFinalTurnUsage(
|
|
92
|
+
latestUsageSnapshot: ContextWindowUsageSnapshot | null,
|
|
93
|
+
accumulatedUsage: ContextWindowUsageSnapshot | null,
|
|
94
|
+
lastKnownContextWindow: number | undefined,
|
|
95
|
+
): ContextWindowUsageSnapshot | null {
|
|
96
|
+
if (!latestUsageSnapshot) return null
|
|
97
|
+
return {
|
|
98
|
+
...latestUsageSnapshot,
|
|
99
|
+
...(typeof lastKnownContextWindow === "number" ? { maxTokens: lastKnownContextWindow } : {}),
|
|
100
|
+
...(accumulatedUsage && accumulatedUsage.usedTokens > latestUsageSnapshot.usedTokens
|
|
101
|
+
? { totalProcessedTokens: accumulatedUsage.usedTokens }
|
|
102
|
+
: {}),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Extracts the maximum context window reported across all models in the SDK's
|
|
108
|
+
* `result.modelUsage` map. Returns undefined when the value is not a
|
|
109
|
+
* record or contains no numeric contextWindow fields.
|
|
110
|
+
*/
|
|
111
|
+
export function maxClaudeContextWindowFromModelUsage<T>(modelUsage: T): number | undefined {
|
|
112
|
+
const record = asRecord(modelUsage)
|
|
113
|
+
if (!record) return undefined
|
|
114
|
+
|
|
115
|
+
let maxContextWindow: number | undefined
|
|
116
|
+
for (const value of Object.values(record)) {
|
|
117
|
+
const usage = asRecord(value)
|
|
118
|
+
const contextWindow = asNumber(usage?.contextWindow) ?? asNumber(usage?.context_window)
|
|
119
|
+
if (contextWindow === undefined) continue
|
|
120
|
+
maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow)
|
|
121
|
+
}
|
|
122
|
+
return maxContextWindow
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The SDK's `result.modelUsage[*].contextWindow` can lie: it reports 200_000 even
|
|
126
|
+
// when the user opted into the 1M beta via the `[1m]` model id suffix
|
|
127
|
+
// (claude-agent-sdk-typescript#238). Without this hint, proactive-compact would
|
|
128
|
+
// trip at 167k tokens — ~17% of the real 1M window — and compact far too often.
|
|
129
|
+
// We derive the configured window from the SDK model id and use it as a floor.
|
|
130
|
+
export function parseConfiguredContextWindowFromModelId(modelId: string): number | undefined {
|
|
131
|
+
return modelId.endsWith("[1m]") ? 1_000_000 : undefined
|
|
132
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { runGit } from "./diff-store"
|
|
6
|
+
import type { DiffStoreBranchOpsDeps } from "./diff-store-branch-ops.adapter"
|
|
7
|
+
import {
|
|
8
|
+
checkoutBranch,
|
|
9
|
+
createBranch,
|
|
10
|
+
listBranches,
|
|
11
|
+
mergeBranch,
|
|
12
|
+
previewMergeBranch,
|
|
13
|
+
syncBranch,
|
|
14
|
+
} from "./diff-store-branch-ops.adapter"
|
|
15
|
+
|
|
16
|
+
async function makeRepo(): Promise<string> {
|
|
17
|
+
const dir = mkdtempSync(path.join(tmpdir(), "kanna-branch-ops-"))
|
|
18
|
+
await runGit(["init", "-b", "main"], dir)
|
|
19
|
+
await runGit(["config", "user.email", "test@kanna.local"], dir)
|
|
20
|
+
await runGit(["config", "user.name", "kanna-test"], dir)
|
|
21
|
+
writeFileSync(path.join(dir, "README.md"), "hello\n")
|
|
22
|
+
await runGit(["add", "README.md"], dir)
|
|
23
|
+
await runGit(["commit", "-m", "init"], dir)
|
|
24
|
+
return dir
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeDeps(overrides: Partial<DiffStoreBranchOpsDeps> = {}): DiffStoreBranchOpsDeps {
|
|
28
|
+
return {
|
|
29
|
+
refreshSnapshot: async () => false,
|
|
30
|
+
...overrides,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("listBranches", () => {
|
|
35
|
+
test("throws when not a git repo", async () => {
|
|
36
|
+
const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
|
|
37
|
+
await expect(listBranches(makeDeps(), { projectPath: dir })).rejects.toThrow("not in a git repository")
|
|
38
|
+
}, 15_000)
|
|
39
|
+
|
|
40
|
+
test("lists current branch in local branches", async () => {
|
|
41
|
+
const repo = await makeRepo()
|
|
42
|
+
const result = await listBranches(makeDeps(), { projectPath: repo })
|
|
43
|
+
expect(result.currentBranchName).toBe("main")
|
|
44
|
+
const localNames = result.local.map((b) => b.name)
|
|
45
|
+
expect(localNames).toContain("main")
|
|
46
|
+
expect(result.local[0]).toMatchObject({ kind: "local", displayName: "main" })
|
|
47
|
+
}, 30_000)
|
|
48
|
+
|
|
49
|
+
test("returns empty remote/pullRequests with no remote", async () => {
|
|
50
|
+
const repo = await makeRepo()
|
|
51
|
+
const result = await listBranches(makeDeps(), { projectPath: repo })
|
|
52
|
+
expect(result.remote).toHaveLength(0)
|
|
53
|
+
expect(result.pullRequests).toHaveLength(0)
|
|
54
|
+
expect(result.pullRequestsStatus).toBe("unavailable")
|
|
55
|
+
}, 30_000)
|
|
56
|
+
|
|
57
|
+
test("lists multiple local branches", async () => {
|
|
58
|
+
const repo = await makeRepo()
|
|
59
|
+
await runGit(["switch", "-c", "feature-a"], repo)
|
|
60
|
+
await runGit(["switch", "main"], repo)
|
|
61
|
+
await runGit(["switch", "-c", "feature-b"], repo)
|
|
62
|
+
await runGit(["switch", "main"], repo)
|
|
63
|
+
|
|
64
|
+
const result = await listBranches(makeDeps(), { projectPath: repo })
|
|
65
|
+
const localNames = result.local.map((b) => b.name)
|
|
66
|
+
expect(localNames).toContain("main")
|
|
67
|
+
expect(localNames).toContain("feature-a")
|
|
68
|
+
expect(localNames).toContain("feature-b")
|
|
69
|
+
}, 30_000)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("createBranch", () => {
|
|
73
|
+
test("throws for empty branch name", async () => {
|
|
74
|
+
const repo = await makeRepo()
|
|
75
|
+
await expect(
|
|
76
|
+
createBranch(makeDeps(), { projectId: "p1", projectPath: repo, name: "" })
|
|
77
|
+
).rejects.toThrow("Branch name is required")
|
|
78
|
+
}, 15_000)
|
|
79
|
+
|
|
80
|
+
test("throws when not a git repo", async () => {
|
|
81
|
+
const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
|
|
82
|
+
await expect(
|
|
83
|
+
createBranch(makeDeps(), { projectId: "p1", projectPath: dir, name: "new-branch" })
|
|
84
|
+
).rejects.toThrow("not in a git repository")
|
|
85
|
+
}, 15_000)
|
|
86
|
+
|
|
87
|
+
test("returns failure for duplicate branch name", async () => {
|
|
88
|
+
const repo = await makeRepo()
|
|
89
|
+
const result = await createBranch(makeDeps(), { projectId: "p1", projectPath: repo, name: "main" })
|
|
90
|
+
expect(result.ok).toBe(false)
|
|
91
|
+
if (!result.ok) {
|
|
92
|
+
expect(result.title).toContain("Create branch failed")
|
|
93
|
+
expect(result.message).toContain("main")
|
|
94
|
+
}
|
|
95
|
+
}, 30_000)
|
|
96
|
+
|
|
97
|
+
test("creates a new branch and switches to it", async () => {
|
|
98
|
+
const repo = await makeRepo()
|
|
99
|
+
const result = await createBranch(makeDeps(), { projectId: "p1", projectPath: repo, name: "my-feature" })
|
|
100
|
+
expect(result.ok).toBe(true)
|
|
101
|
+
if (result.ok) {
|
|
102
|
+
expect(result.branchName).toBe("my-feature")
|
|
103
|
+
expect(result.snapshotChanged).toBe(false)
|
|
104
|
+
}
|
|
105
|
+
}, 30_000)
|
|
106
|
+
|
|
107
|
+
test("creates branch from explicit base branch", async () => {
|
|
108
|
+
const repo = await makeRepo()
|
|
109
|
+
const result = await createBranch(makeDeps(), {
|
|
110
|
+
projectId: "p1",
|
|
111
|
+
projectPath: repo,
|
|
112
|
+
name: "from-main",
|
|
113
|
+
baseBranchName: "main",
|
|
114
|
+
})
|
|
115
|
+
expect(result.ok).toBe(true)
|
|
116
|
+
if (result.ok) {
|
|
117
|
+
expect(result.branchName).toBe("from-main")
|
|
118
|
+
}
|
|
119
|
+
}, 30_000)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe("checkoutBranch", () => {
|
|
123
|
+
test("throws when not a git repo", async () => {
|
|
124
|
+
const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
|
|
125
|
+
await expect(
|
|
126
|
+
checkoutBranch(makeDeps(), {
|
|
127
|
+
projectId: "p1",
|
|
128
|
+
projectPath: dir,
|
|
129
|
+
branch: { kind: "local", name: "main" },
|
|
130
|
+
})
|
|
131
|
+
).rejects.toThrow("not in a git repository")
|
|
132
|
+
}, 15_000)
|
|
133
|
+
|
|
134
|
+
test("cancels when dirty paths and bringChanges is false", async () => {
|
|
135
|
+
const repo = await makeRepo()
|
|
136
|
+
// Create another branch to checkout
|
|
137
|
+
await runGit(["switch", "-c", "other"], repo)
|
|
138
|
+
await runGit(["switch", "main"], repo)
|
|
139
|
+
// Dirty the working tree
|
|
140
|
+
writeFileSync(path.join(repo, "dirty.txt"), "changes\n")
|
|
141
|
+
|
|
142
|
+
const result = await checkoutBranch(makeDeps(), {
|
|
143
|
+
projectId: "p1",
|
|
144
|
+
projectPath: repo,
|
|
145
|
+
branch: { kind: "local", name: "other" },
|
|
146
|
+
bringChanges: false,
|
|
147
|
+
})
|
|
148
|
+
expect(result.ok).toBe(false)
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
expect(result.cancelled).toBe(true)
|
|
151
|
+
expect(result.title).toContain("cancelled")
|
|
152
|
+
}
|
|
153
|
+
}, 30_000)
|
|
154
|
+
|
|
155
|
+
test("switches to a local branch", async () => {
|
|
156
|
+
const repo = await makeRepo()
|
|
157
|
+
await runGit(["switch", "-c", "feature-x"], repo)
|
|
158
|
+
await runGit(["switch", "main"], repo)
|
|
159
|
+
|
|
160
|
+
const result = await checkoutBranch(makeDeps(), {
|
|
161
|
+
projectId: "p1",
|
|
162
|
+
projectPath: repo,
|
|
163
|
+
branch: { kind: "local", name: "feature-x" },
|
|
164
|
+
})
|
|
165
|
+
expect(result.ok).toBe(true)
|
|
166
|
+
if (result.ok) {
|
|
167
|
+
expect(result.branchName).toBe("feature-x")
|
|
168
|
+
}
|
|
169
|
+
}, 30_000)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe("previewMergeBranch", () => {
|
|
173
|
+
test("throws when not a git repo", async () => {
|
|
174
|
+
const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
|
|
175
|
+
await expect(
|
|
176
|
+
previewMergeBranch(makeDeps(), {
|
|
177
|
+
projectPath: dir,
|
|
178
|
+
branch: { kind: "local", name: "main" },
|
|
179
|
+
})
|
|
180
|
+
).rejects.toThrow("not in a git repository")
|
|
181
|
+
}, 15_000)
|
|
182
|
+
|
|
183
|
+
test("returns up_to_date when targeting current branch", async () => {
|
|
184
|
+
const repo = await makeRepo()
|
|
185
|
+
const result = await previewMergeBranch(makeDeps(), {
|
|
186
|
+
projectPath: repo,
|
|
187
|
+
branch: { kind: "local", name: "main" },
|
|
188
|
+
})
|
|
189
|
+
expect(result.status).toBe("up_to_date")
|
|
190
|
+
expect(result.currentBranchName).toBe("main")
|
|
191
|
+
expect(result.targetBranchName).toBe("main")
|
|
192
|
+
}, 30_000)
|
|
193
|
+
|
|
194
|
+
test("returns up_to_date when branch has no new commits", async () => {
|
|
195
|
+
const repo = await makeRepo()
|
|
196
|
+
// Create a branch but don't add commits — so it has 0 commits ahead of main
|
|
197
|
+
await runGit(["switch", "-c", "empty-branch"], repo)
|
|
198
|
+
await runGit(["switch", "main"], repo)
|
|
199
|
+
|
|
200
|
+
const result = await previewMergeBranch(makeDeps(), {
|
|
201
|
+
projectPath: repo,
|
|
202
|
+
branch: { kind: "local", name: "empty-branch" },
|
|
203
|
+
})
|
|
204
|
+
expect(result.status).toBe("up_to_date")
|
|
205
|
+
expect(result.commitCount).toBe(0)
|
|
206
|
+
}, 30_000)
|
|
207
|
+
|
|
208
|
+
test("returns mergeable when branch has new commits", async () => {
|
|
209
|
+
const repo = await makeRepo()
|
|
210
|
+
// Create a branch with a commit
|
|
211
|
+
await runGit(["switch", "-c", "with-commits"], repo)
|
|
212
|
+
writeFileSync(path.join(repo, "new-file.txt"), "content\n")
|
|
213
|
+
await runGit(["add", "new-file.txt"], repo)
|
|
214
|
+
await runGit(["commit", "-m", "add new file"], repo)
|
|
215
|
+
await runGit(["switch", "main"], repo)
|
|
216
|
+
|
|
217
|
+
const result = await previewMergeBranch(makeDeps(), {
|
|
218
|
+
projectPath: repo,
|
|
219
|
+
branch: { kind: "local", name: "with-commits" },
|
|
220
|
+
})
|
|
221
|
+
expect(result.status).toBe("mergeable")
|
|
222
|
+
expect(result.commitCount).toBe(1)
|
|
223
|
+
expect(result.hasConflicts).toBe(false)
|
|
224
|
+
}, 30_000)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe("mergeBranch", () => {
|
|
228
|
+
test("throws when not a git repo", async () => {
|
|
229
|
+
const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
|
|
230
|
+
await expect(
|
|
231
|
+
mergeBranch(makeDeps(), {
|
|
232
|
+
projectId: "p1",
|
|
233
|
+
projectPath: dir,
|
|
234
|
+
branch: { kind: "local", name: "main" },
|
|
235
|
+
})
|
|
236
|
+
).rejects.toThrow("not in a git repository")
|
|
237
|
+
}, 15_000)
|
|
238
|
+
|
|
239
|
+
test("returns failure when working tree is dirty", async () => {
|
|
240
|
+
const repo = await makeRepo()
|
|
241
|
+
writeFileSync(path.join(repo, "dirty.txt"), "changes\n")
|
|
242
|
+
const result = await mergeBranch(makeDeps(), {
|
|
243
|
+
projectId: "p1",
|
|
244
|
+
projectPath: repo,
|
|
245
|
+
branch: { kind: "local", name: "main" },
|
|
246
|
+
})
|
|
247
|
+
expect(result.ok).toBe(false)
|
|
248
|
+
if (!result.ok) {
|
|
249
|
+
expect(result.title).toContain("Merge blocked")
|
|
250
|
+
}
|
|
251
|
+
}, 30_000)
|
|
252
|
+
|
|
253
|
+
test("returns failure when already up to date", async () => {
|
|
254
|
+
const repo = await makeRepo()
|
|
255
|
+
await runGit(["switch", "-c", "no-new-commits"], repo)
|
|
256
|
+
await runGit(["switch", "main"], repo)
|
|
257
|
+
const result = await mergeBranch(makeDeps(), {
|
|
258
|
+
projectId: "p1",
|
|
259
|
+
projectPath: repo,
|
|
260
|
+
branch: { kind: "local", name: "no-new-commits" },
|
|
261
|
+
})
|
|
262
|
+
expect(result.ok).toBe(false)
|
|
263
|
+
if (!result.ok) {
|
|
264
|
+
expect(result.title).toContain("Already up to date")
|
|
265
|
+
}
|
|
266
|
+
}, 30_000)
|
|
267
|
+
|
|
268
|
+
test("merges a branch with new commits", async () => {
|
|
269
|
+
const repo = await makeRepo()
|
|
270
|
+
await runGit(["switch", "-c", "to-merge"], repo)
|
|
271
|
+
writeFileSync(path.join(repo, "merged-file.txt"), "content\n")
|
|
272
|
+
await runGit(["add", "merged-file.txt"], repo)
|
|
273
|
+
await runGit(["commit", "-m", "add merged file"], repo)
|
|
274
|
+
await runGit(["switch", "main"], repo)
|
|
275
|
+
|
|
276
|
+
const result = await mergeBranch(makeDeps(), {
|
|
277
|
+
projectId: "p1",
|
|
278
|
+
projectPath: repo,
|
|
279
|
+
branch: { kind: "local", name: "to-merge" },
|
|
280
|
+
})
|
|
281
|
+
expect(result.ok).toBe(true)
|
|
282
|
+
if (result.ok) {
|
|
283
|
+
expect(result.branchName).toBe("main")
|
|
284
|
+
}
|
|
285
|
+
}, 30_000)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe("syncBranch", () => {
|
|
289
|
+
test("throws when not a git repo", async () => {
|
|
290
|
+
const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
|
|
291
|
+
await expect(
|
|
292
|
+
syncBranch(makeDeps(), { projectId: "p1", projectPath: dir, action: "fetch" })
|
|
293
|
+
).rejects.toThrow("not in a git repository")
|
|
294
|
+
}, 15_000)
|
|
295
|
+
|
|
296
|
+
test("returns failure for push without upstream", async () => {
|
|
297
|
+
const repo = await makeRepo()
|
|
298
|
+
const result = await syncBranch(makeDeps(), { projectId: "p1", projectPath: repo, action: "push" })
|
|
299
|
+
expect(result.ok).toBe(false)
|
|
300
|
+
if (!result.ok) {
|
|
301
|
+
expect(result.title).toContain("Push failed")
|
|
302
|
+
expect(result.message).toContain("upstream")
|
|
303
|
+
}
|
|
304
|
+
}, 30_000)
|
|
305
|
+
|
|
306
|
+
test("returns failure for pull without upstream", async () => {
|
|
307
|
+
const repo = await makeRepo()
|
|
308
|
+
const result = await syncBranch(makeDeps(), { projectId: "p1", projectPath: repo, action: "pull" })
|
|
309
|
+
expect(result.ok).toBe(false)
|
|
310
|
+
if (!result.ok) {
|
|
311
|
+
expect(result.title).toContain("Pull failed")
|
|
312
|
+
expect(result.message).toContain("upstream")
|
|
313
|
+
}
|
|
314
|
+
}, 30_000)
|
|
315
|
+
|
|
316
|
+
test("fetch returns failure when no remote configured", async () => {
|
|
317
|
+
const repo = await makeRepo()
|
|
318
|
+
const result = await syncBranch(makeDeps(), { projectId: "p1", projectPath: repo, action: "fetch" })
|
|
319
|
+
// fetch --all --prune with no remotes: git either returns non-zero or returns ok with nothing to fetch
|
|
320
|
+
// Either is acceptable — just verify we get a response
|
|
321
|
+
expect(result.action).toBe("fetch")
|
|
322
|
+
}, 30_000)
|
|
323
|
+
|
|
324
|
+
test("publish returns failure when no remote configured", async () => {
|
|
325
|
+
const repo = await makeRepo()
|
|
326
|
+
const result = await syncBranch(makeDeps(), { projectId: "p1", projectPath: repo, action: "publish" })
|
|
327
|
+
expect(result.ok).toBe(false)
|
|
328
|
+
if (!result.ok) {
|
|
329
|
+
expect(result.action).toBe("publish")
|
|
330
|
+
}
|
|
331
|
+
}, 30_000)
|
|
332
|
+
})
|