@cspeach/cli 0.6.4 → 0.7.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.
Files changed (153) hide show
  1. package/dist/agent/intent-system-prompt.js +46 -0
  2. package/dist/agent/loop.js +180 -18
  3. package/dist/agent/parallel-write-guard.js +71 -0
  4. package/dist/agent/repair-partial.js +88 -1
  5. package/dist/agent/retry-cap.js +121 -0
  6. package/dist/agent/summarise-via-provider.js +51 -0
  7. package/dist/approvals/jwt.js +33 -12
  8. package/dist/commands/auto-compact.js +94 -0
  9. package/dist/commands/compact.js +265 -0
  10. package/dist/commands/cost.js +56 -0
  11. package/dist/commands/help.js +21 -14
  12. package/dist/config/loader.js +39 -0
  13. package/dist/cost/cost-log.js +113 -0
  14. package/dist/cost/pricing.js +91 -0
  15. package/dist/index.js +0 -0
  16. package/dist/one-shot.js +1 -1
  17. package/dist/renderer/markdown.js +7 -1
  18. package/dist/renderer/status-footer.js +37 -14
  19. package/dist/renderer/thinking-heartbeat.js +15 -3
  20. package/dist/renderer/tool-widget.js +6 -1
  21. package/dist/renderer/tty.js +27 -0
  22. package/dist/repl/cspeach-shell-detect.js +33 -0
  23. package/dist/repl/post-turn-status.js +68 -0
  24. package/dist/repl/slash-completer.js +2 -0
  25. package/dist/repl/slash-picker.js +2 -0
  26. package/dist/repl.js +371 -46
  27. package/dist/sap-errors/parse-adt-exception.js +149 -0
  28. package/dist/session/schema.js +2 -1
  29. package/dist/session/store.js +19 -0
  30. package/dist/tools/_filesystem-shared.js +9 -1
  31. package/dist/tools/ask-question.js +19 -0
  32. package/dist/tools/sap-read.js +56 -4
  33. package/dist/tools/sap-write.js +19 -0
  34. package/dist/tools/subagent/schedule_draft_create.js +137 -0
  35. package/dist/ui/alt-screen.js +120 -0
  36. package/dist/ui/app.js +39 -15
  37. package/dist/ui/ask-question-emitter.js +13 -0
  38. package/dist/ui/body.js +39 -23
  39. package/dist/ui/coaching-picker-classic.js +8 -1
  40. package/dist/ui/footer.js +6 -1
  41. package/dist/ui/header.js +19 -4
  42. package/dist/ui/sap-state-store.js +13 -1
  43. package/dist/ui/sidebar.js +5 -3
  44. package/dist/ui/status-emitter.js +19 -0
  45. package/dist/ui/status-row.js +30 -4
  46. package/dist/ui/widgets/ask-question-modal.js +132 -0
  47. package/dist/ui/widgets/coaching-picker.js +55 -8
  48. package/package.json +86 -82
  49. package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
  50. package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
  51. package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
  52. package/dist/agent/__tests__/project-context-injection.test.js +0 -161
  53. package/dist/agent/__tests__/repair-partial.test.js +0 -79
  54. package/dist/agent/__tests__/retry-key.test.js +0 -77
  55. package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
  56. package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
  57. package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
  58. package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
  59. package/dist/agent/__tests__/turn-stream.test.js +0 -100
  60. package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
  61. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
  62. package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
  63. package/dist/agent/providers/__tests__/factory.test.js +0 -33
  64. package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
  65. package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
  66. package/dist/approvals/__tests__/advisory-render.test.js +0 -54
  67. package/dist/auth/__tests__/auth-file.test.js +0 -42
  68. package/dist/auth/__tests__/me.test.js +0 -43
  69. package/dist/classifier/__tests__/client.test.js +0 -70
  70. package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
  71. package/dist/commands/__tests__/login.test.js +0 -152
  72. package/dist/commands/__tests__/logout.test.js +0 -39
  73. package/dist/commands/__tests__/project-context-impact.test.js +0 -173
  74. package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
  75. package/dist/commands/__tests__/whoami.test.js +0 -91
  76. package/dist/config/__tests__/llm-config.test.js +0 -28
  77. package/dist/config/__tests__/shell-exec-config.test.js +0 -76
  78. package/dist/config/__tests__/write-mode.test.js +0 -36
  79. package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
  80. package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
  81. package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
  82. package/dist/project-context/__tests__/conventions.test.js +0 -221
  83. package/dist/project-context/__tests__/detect.test.js +0 -177
  84. package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
  85. package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
  86. package/dist/project-context/__tests__/domain-cap.test.js +0 -168
  87. package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
  88. package/dist/project-context/__tests__/git.test.js +0 -131
  89. package/dist/project-context/__tests__/index-files.test.js +0 -195
  90. package/dist/project-context/__tests__/index.test.js +0 -133
  91. package/dist/project-context/__tests__/render.test.js +0 -322
  92. package/dist/project-context/__tests__/types.test.js +0 -73
  93. package/dist/projects/__tests__/build.test.js +0 -52
  94. package/dist/projects/__tests__/canonicalize.test.js +0 -47
  95. package/dist/projects/__tests__/email-template.test.js +0 -71
  96. package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
  97. package/dist/projects/__tests__/extract-cca.test.js +0 -141
  98. package/dist/projects/__tests__/extract-design.test.js +0 -62
  99. package/dist/projects/__tests__/extract-estimate.test.js +0 -58
  100. package/dist/projects/__tests__/extract-modernize.test.js +0 -127
  101. package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
  102. package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
  103. package/dist/projects/__tests__/filename.test.js +0 -32
  104. package/dist/projects/__tests__/promote-command.test.js +0 -170
  105. package/dist/projects/__tests__/promote.test.js +0 -181
  106. package/dist/projects/__tests__/save-command.test.js +0 -216
  107. package/dist/projects/__tests__/save.test.js +0 -41
  108. package/dist/projects/__tests__/status.test.js +0 -208
  109. package/dist/projects/__tests__/types.test.js +0 -41
  110. package/dist/projects/__tests__/validate.test.js +0 -165
  111. package/dist/projects/__tests__/workspace.test.js +0 -337
  112. package/dist/repl/__tests__/current-transport.test.js +0 -43
  113. package/dist/repl/__tests__/diff-display.test.js +0 -59
  114. package/dist/repl/__tests__/file-picker.test.js +0 -97
  115. package/dist/repl/__tests__/rule8-detector.test.js +0 -114
  116. package/dist/repl/__tests__/safety-confirm.test.js +0 -130
  117. package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
  118. package/dist/sap/__tests__/system-info.test.js +0 -344
  119. package/dist/session/__tests__/store-discovery.test.js +0 -114
  120. package/dist/session/__tests__/time-ago.test.js +0 -53
  121. package/dist/skills/__tests__/canonical.test.js +0 -20
  122. package/dist/skills/__tests__/manifest-client.test.js +0 -88
  123. package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
  124. package/dist/skills/__tests__/source-managed.test.js +0 -60
  125. package/dist/tools/__tests__/_background-shared.test.js +0 -69
  126. package/dist/tools/__tests__/_command-shared.test.js +0 -59
  127. package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
  128. package/dist/tools/__tests__/_flag.test.js +0 -66
  129. package/dist/tools/__tests__/_helpers.js +0 -17
  130. package/dist/tools/__tests__/_web-shared.test.js +0 -143
  131. package/dist/tools/__tests__/agent_run.test.js +0 -166
  132. package/dist/tools/__tests__/approval-advisory.test.js +0 -66
  133. package/dist/tools/__tests__/background_run.test.js +0 -113
  134. package/dist/tools/__tests__/convention_get.test.js +0 -53
  135. package/dist/tools/__tests__/file-edit.test.js +0 -72
  136. package/dist/tools/__tests__/file-read.test.js +0 -81
  137. package/dist/tools/__tests__/file-write.test.js +0 -68
  138. package/dist/tools/__tests__/glob.test.js +0 -128
  139. package/dist/tools/__tests__/grep.test.js +0 -71
  140. package/dist/tools/__tests__/monitor_emit.test.js +0 -96
  141. package/dist/tools/__tests__/playbook_get.test.js +0 -109
  142. package/dist/tools/__tests__/project_context_get.test.js +0 -71
  143. package/dist/tools/__tests__/registry-category.test.js +0 -60
  144. package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
  145. package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
  146. package/dist/tools/__tests__/schedule_create.test.js +0 -85
  147. package/dist/tools/__tests__/shell_exec.test.js +0 -135
  148. package/dist/tools/__tests__/transport-safety.test.js +0 -106
  149. package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
  150. package/dist/tools/__tests__/web_fetch.test.js +0 -329
  151. package/dist/tools/__tests__/web_search.test.js +0 -215
  152. package/dist/tools/__tests__/write-mode.test.js +0 -32
  153. package/dist/ui/__tests__/login-banner.test.js +0 -69
package/package.json CHANGED
@@ -1,82 +1,86 @@
1
- {
2
- "name": "@cspeach/cli",
3
- "version": "0.6.4",
4
- "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
- "keywords": [
6
- "abap",
7
- "sap",
8
- "ai",
9
- "cli",
10
- "ai-assisted-development",
11
- "s4hana",
12
- "abap-development",
13
- "claude",
14
- "anthropic"
15
- ],
16
- "homepage": "https://cspeach.dev",
17
- "bugs": {
18
- "email": "laeeq.siddique@cremencing.com"
19
- },
20
- "author": "Laeeq Siddique (Cremencing Solutions)",
21
- "license": "SEE LICENSE IN LICENSE",
22
- "private": false,
23
- "publishConfig": {
24
- "access": "public"
25
- },
26
- "type": "module",
27
- "bin": {
28
- "cspeach": "dist/index.js"
29
- },
30
- "engines": {
31
- "node": ">=20.0.0"
32
- },
33
- "dependencies": {
34
- "@anthropic-ai/sdk": "^0.90.0",
35
- "@cspeach/sap-client": "^0.1.3",
36
- "@iarna/toml": "^2.2.5",
37
- "@inkjs/ui": "^2.0.0",
38
- "@inquirer/prompts": "^7.0.0",
39
- "@noble/ed25519": "2.1.0",
40
- "@noble/hashes": "1.4.0",
41
- "chalk": "^5.3.0",
42
- "cli-highlight": "^2.1.11",
43
- "cli-table3": "^0.6.5",
44
- "clipboardy": "^5.3.1",
45
- "diff": "^9.0.0",
46
- "fuse.js": "^7.0.0",
47
- "ink": "^5.0.0",
48
- "ink-text-input": "^6.0.0",
49
- "jose": "^5.9.0",
50
- "keytar": "^7.9.0",
51
- "marked": "^11.2.0",
52
- "marked-terminal": "^6.2.0",
53
- "node-machine-id": "^1.1.12",
54
- "openai": "4.67.3",
55
- "react": "^18.3.0",
56
- "zod": "^3.23.0"
57
- },
58
- "devDependencies": {
59
- "@types/diff": "^7.0.2",
60
- "@types/node": "^20.17.0",
61
- "@types/react": "^18.3.0",
62
- "husky": "^9.1.7",
63
- "ink-testing-library": "^4.0.0",
64
- "tsx": "^4.19.0",
65
- "typescript": "^5.7.0",
66
- "vitest": "^2.1.0"
67
- },
68
- "scripts": {
69
- "dev": "tsx src/index.ts",
70
- "prebuild": "tsx scripts/prebuild.ts",
71
- "build": "tsc && node scripts/make-bin-executable.js",
72
- "install:global": "tsx scripts/install-global.ts",
73
- "pretest": "tsx scripts/sync-signing-key.ts || node -e \"console.warn('WARN: sync-signing-key skipped — using stub')\"",
74
- "start": "node dist/index.js",
75
- "typecheck": "tsc --noEmit",
76
- "test": "vitest run",
77
- "test:watch": "vitest",
78
- "adt:smoke": "tsx scripts/adt-smoke.ts",
79
- "smoke:all": "pnpm zcspeach:smoke && pnpm adt:smoke",
80
- "zcspeach:smoke": "tsx scripts/zcspeach-smoke.ts"
81
- }
82
- }
1
+ {
2
+ "name": "@cspeach/cli",
3
+ "version": "0.7.0",
4
+ "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
+ "keywords": [
6
+ "abap",
7
+ "sap",
8
+ "ai",
9
+ "cli",
10
+ "ai-assisted-development",
11
+ "s4hana",
12
+ "abap-development",
13
+ "claude",
14
+ "anthropic"
15
+ ],
16
+ "homepage": "https://cspeach.dev",
17
+ "bugs": {
18
+ "email": "laeeq.siddique@cremencing.com"
19
+ },
20
+ "author": "Laeeq Siddique (Cremencing Solutions)",
21
+ "license": "SEE LICENSE IN LICENSE",
22
+ "private": false,
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "bin": {
28
+ "cspeach": "dist/index.js"
29
+ },
30
+ "scripts": {
31
+ "dev": "tsx src/index.ts",
32
+ "prebuild": "tsx scripts/prebuild.ts",
33
+ "build": "tsc && node scripts/make-bin-executable.js",
34
+ "install:global": "tsx scripts/install-global.ts",
35
+ "pretest": "tsx scripts/sync-signing-key.ts || node -e \"console.warn('WARN: sync-signing-key skipped — using stub')\"",
36
+ "start": "node dist/index.js",
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "adt:smoke": "tsx scripts/adt-smoke.ts",
41
+ "smoke:all": "pnpm zcspeach:smoke && pnpm adt:smoke",
42
+ "zcspeach:smoke": "tsx scripts/zcspeach-smoke.ts",
43
+ "prepare": "husky"
44
+ },
45
+ "engines": {
46
+ "node": ">=20.0.0"
47
+ },
48
+ "dependencies": {
49
+ "@anthropic-ai/sdk": "^0.90.0",
50
+ "@cspeach/sap-client": "^0.1.3",
51
+ "@iarna/toml": "^2.2.5",
52
+ "@inkjs/ui": "^2.0.0",
53
+ "@inquirer/prompts": "^7.0.0",
54
+ "@noble/ed25519": "2.1.0",
55
+ "@noble/hashes": "1.4.0",
56
+ "chalk": "^5.3.0",
57
+ "cli-highlight": "^2.1.11",
58
+ "cli-table3": "^0.6.5",
59
+ "clipboardy": "^5.3.1",
60
+ "diff": "^9.0.0",
61
+ "fuse.js": "^7.0.0",
62
+ "ink": "^5.0.0",
63
+ "ink-text-input": "^6.0.0",
64
+ "jose": "^5.9.0",
65
+ "keytar": "^7.9.0",
66
+ "marked": "^11.2.0",
67
+ "marked-terminal": "^6.2.0",
68
+ "node-machine-id": "^1.1.12",
69
+ "openai": "4.67.3",
70
+ "react": "^18.3.0",
71
+ "zod": "^3.23.0"
72
+ },
73
+ "devDependencies": {
74
+ "@types/diff": "^7.0.2",
75
+ "@types/node": "^20.17.0",
76
+ "@types/react": "^18.3.0",
77
+ "husky": "^9.1.7",
78
+ "ink-testing-library": "^4.0.0",
79
+ "tsx": "^4.19.0",
80
+ "typescript": "^5.7.0",
81
+ "vitest": "^2.1.0"
82
+ },
83
+ "pnpm": {
84
+ "onlyBuiltDependencies": ["keytar", "esbuild"]
85
+ }
86
+ }
@@ -1,254 +0,0 @@
1
- /**
2
- * Round B integration test — crash + recover round-trip.
3
- *
4
- * Exercises the same try/catch + repair + persist pattern that lives in
5
- * agent/loop.ts (lines ~327-460) without spinning up a real LLMProvider or
6
- * inquirer prompts. The harness below mirrors the loop's structure exactly:
7
- *
8
- * try {
9
- * for await (event of stream) { accumulate into currentAssistantContent }
10
- * } catch (err) {
11
- * interruptedError = err;
12
- * }
13
- * if (interruptedError) currentAssistantContent = repairPartialBlocks(...);
14
- * session.messages.push({ role: 'assistant', content: currentAssistantContent });
15
- * session.turnInterrupted = (interruptedError != null);
16
- * await saveSession(session);
17
- *
18
- * If the loop's actual code drifts from this contract, the diff between the
19
- * two will surface in code review. This test guarantees that whichever code
20
- * follows the contract behaves correctly under crash conditions.
21
- *
22
- * The key assertion: the LOST 200K-token report from /abap-cca on
23
- * ZDPR_PAYMENTS — partial assistant text streamed before the Undici stream
24
- * "terminated" mid-flight — IS recoverable from disk after this contract
25
- * runs.
26
- */
27
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
28
- import { promises as fs } from 'node:fs';
29
- import * as os from 'node:os';
30
- import * as path from 'node:path';
31
- import { newSession } from '../../session/schema.js';
32
- import { saveSession, loadSession } from '../../session/store.js';
33
- import { repairPartialBlocks } from '../repair-partial.js';
34
- const ORIGINAL_HOME = os.homedir();
35
- describe('crash + recover round-trip (Round B)', () => {
36
- let tempHome;
37
- beforeEach(async () => {
38
- tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'crash-recover-'));
39
- process.env.HOME = tempHome;
40
- process.env.USERPROFILE = tempHome;
41
- });
42
- afterEach(async () => {
43
- process.env.HOME = ORIGINAL_HOME;
44
- process.env.USERPROFILE = ORIGINAL_HOME;
45
- await fs.rm(tempHome, { recursive: true, force: true });
46
- });
47
- /**
48
- * Build a fake provider stream that yields the supplied events and then
49
- * either completes normally OR throws the supplied error before exhausting
50
- * the iterable. Mirrors the shape of the Anthropic SDK's MessageStream.
51
- */
52
- function makeFakeStream(events, throwAfter, error) {
53
- return {
54
- async *[Symbol.asyncIterator]() {
55
- let i = 0;
56
- for (const ev of events) {
57
- if (i >= throwAfter && error) {
58
- throw error;
59
- }
60
- yield ev;
61
- i++;
62
- }
63
- if (error)
64
- throw error;
65
- },
66
- };
67
- }
68
- /**
69
- * Mini-harness that mirrors the agent loop's for-await + try/catch + repair
70
- * + persist pattern. Returns the accumulated content + the interrupted
71
- * error so tests can assert on both. Crucially, it ALWAYS saves the
72
- * session — same contract as the real loop.
73
- */
74
- async function runTurnHarness(session, stream) {
75
- let currentAssistantContent = [];
76
- let interruptedError = null;
77
- try {
78
- for await (const event of stream) {
79
- if (event.type === 'content_block_start') {
80
- currentAssistantContent.push(event.content_block);
81
- }
82
- else if (event.type === 'content_block_delta') {
83
- const last = currentAssistantContent[currentAssistantContent.length - 1];
84
- const d = event.delta;
85
- if (d.type === 'text_delta' && last?.type === 'text') {
86
- last.text = (last.text ?? '') + d.text;
87
- }
88
- else if (d.type === 'thinking_delta' && last?.type === 'thinking') {
89
- last.thinking = (last.thinking ?? '') + d.thinking;
90
- }
91
- else if (d.type === 'input_json_delta') {
92
- if (last)
93
- last.partial_json = (last.partial_json ?? '') + d.partial_json;
94
- }
95
- }
96
- }
97
- }
98
- catch (err) {
99
- interruptedError = err;
100
- }
101
- if (interruptedError !== null) {
102
- currentAssistantContent = repairPartialBlocks(currentAssistantContent);
103
- }
104
- if (currentAssistantContent.length > 0) {
105
- session.messages.push({ role: 'assistant', content: currentAssistantContent });
106
- }
107
- session.last_turn_at = new Date().toISOString();
108
- if (interruptedError !== null) {
109
- session.turnInterrupted = true;
110
- const msg = interruptedError instanceof Error ? interruptedError.message : String(interruptedError);
111
- session.turnInterruptedReason = msg.slice(0, 200);
112
- }
113
- else {
114
- session.turnInterrupted = false;
115
- session.turnInterruptedReason = undefined;
116
- }
117
- await saveSession(session);
118
- return { assistantContent: currentAssistantContent, interruptedError };
119
- }
120
- it('persists partial assistant content when the stream throws after a thinking + text block', async () => {
121
- const session = newSession('crash-1', 'S4H', 'abap-cca', 'claude-opus-4-7');
122
- session.messages.push({ role: 'user', content: '/abap-cca on ZDPR_PAYMENTS' });
123
- const events = [
124
- { type: 'content_block_start', content_block: { type: 'thinking', thinking: '' } },
125
- { type: 'content_block_delta', delta: { type: 'thinking_delta', thinking: 'Plan probes...' } },
126
- { type: 'content_block_start', content_block: { type: 'text', text: '' } },
127
- { type: 'content_block_delta', delta: { type: 'text_delta', text: '## ABAP CCA — ZDPR_PAYMENTS\n\n## TL;DR\n\n13-object package' } },
128
- { type: 'content_block_delta', delta: { type: 'text_delta', text: ', 5+ years untouched...' } },
129
- ];
130
- const stream = makeFakeStream(events, events.length, new Error('terminated'));
131
- const result = await runTurnHarness(session, stream);
132
- expect(result.interruptedError).toBeInstanceOf(Error);
133
- expect(result.interruptedError.message).toBe('terminated');
134
- expect(session.turnInterrupted).toBe(true);
135
- expect(session.turnInterruptedReason).toBe('terminated');
136
- // The partial assistant message WAS saved.
137
- expect(session.messages).toHaveLength(2);
138
- const assistant = session.messages[1];
139
- expect(assistant.role).toBe('assistant');
140
- const content = assistant.content;
141
- expect(content).toHaveLength(2); // thinking + text
142
- expect(content[0].type).toBe('thinking');
143
- expect(content[0].thinking).toContain('Plan probes...');
144
- expect(content[1].type).toBe('text');
145
- expect(content[1].text).toContain('ZDPR_PAYMENTS');
146
- expect(content[1].text).toContain('5+ years untouched');
147
- });
148
- it('drops incomplete tool_use blocks (partial_json) when the stream throws mid-input_json_delta', async () => {
149
- const session = newSession('crash-2', 'S4H', 'abap-cca', 'claude-opus-4-7');
150
- session.messages.push({ role: 'user', content: '/abap-cca' });
151
- const events = [
152
- { type: 'content_block_start', content_block: { type: 'text', text: '' } },
153
- { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Querying TADIR...' } },
154
- // A tool_use block whose JSON never finished arriving.
155
- { type: 'content_block_start', content_block: { type: 'tool_use', id: 'tu_1', name: 'sap_sql_query' } },
156
- { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{"query":"SEL' } },
157
- ];
158
- const stream = makeFakeStream(events, events.length, new Error('terminated'));
159
- await runTurnHarness(session, stream);
160
- expect(session.turnInterrupted).toBe(true);
161
- const assistant = session.messages[1];
162
- const content = assistant.content;
163
- // text block survived; the partial tool_use was dropped by repair.
164
- expect(content).toHaveLength(1);
165
- expect(content[0].type).toBe('text');
166
- expect(content[0].text).toBe('Querying TADIR...');
167
- });
168
- it('round-trips the saved session through loadSession with all partial work intact', async () => {
169
- const session = newSession('crash-3', 'S4H', 'abap-cca', 'claude-opus-4-7');
170
- session.messages.push({ role: 'user', content: '/abap-cca on ZDPR_PAYMENTS' });
171
- const events = [
172
- { type: 'content_block_start', content_block: { type: 'text', text: '' } },
173
- { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Headline: 13 objects.' } },
174
- ];
175
- const stream = makeFakeStream(events, events.length, new Error('terminated'));
176
- await runTurnHarness(session, stream);
177
- // Round-trip through disk.
178
- const reloaded = await loadSession('crash-3');
179
- expect(reloaded.id).toBe('crash-3');
180
- expect(reloaded.turnInterrupted).toBe(true);
181
- expect(reloaded.turnInterruptedReason).toBe('terminated');
182
- expect(reloaded.messages).toHaveLength(2);
183
- const assistant = reloaded.messages[1];
184
- const content = assistant.content;
185
- expect(content[0].text).toBe('Headline: 13 objects.');
186
- });
187
- it('clean-completion path saves with turnInterrupted=false (clears stale flag)', async () => {
188
- const session = newSession('clean-1', 'S4H', 'abap-cca', 'claude-opus-4-7');
189
- // Simulate a session that was previously interrupted...
190
- session.turnInterrupted = true;
191
- session.turnInterruptedReason = 'old';
192
- session.messages.push({ role: 'user', content: 'continue' });
193
- const events = [
194
- { type: 'content_block_start', content_block: { type: 'text', text: '' } },
195
- { type: 'content_block_delta', delta: { type: 'text_delta', text: 'OK done.' } },
196
- ];
197
- const stream = makeFakeStream(events, events.length); // no error → clean run
198
- await runTurnHarness(session, stream);
199
- // Stale flag cleared on clean completion.
200
- expect(session.turnInterrupted).toBe(false);
201
- expect(session.turnInterruptedReason).toBeUndefined();
202
- });
203
- it('REGRESSION: the lost ZDPR_PAYMENTS scenario — 200K-equivalent partial report survives', async () => {
204
- // Simulates the actual incident: model emits a thinking block, then a
205
- // long final report, then the provider stream "terminated" mid-text.
206
- const session = newSession('17c04ce8-sim', 'S4H', 'abap-cca', 'claude-opus-4-7');
207
- session.messages.push({ role: 'user', content: '/abap-cca on ZDPR_PAYMENTS' });
208
- // Build a 200KB-ish text payload across many deltas — same shape the real
209
- // model would produce streaming a long consulting report.
210
- const events = [
211
- { type: 'content_block_start', content_block: { type: 'thinking', thinking: '' } },
212
- { type: 'content_block_delta', delta: { type: 'thinking_delta', thinking: 'Synthesise findings.' } },
213
- { type: 'content_block_start', content_block: { type: 'text', text: '' } },
214
- ];
215
- for (let i = 0; i < 1000; i++) {
216
- events.push({
217
- type: 'content_block_delta',
218
- delta: { type: 'text_delta', text: `paragraph ${i}: ${'lorem ipsum '.repeat(15)}\n` },
219
- });
220
- }
221
- const stream = makeFakeStream(events, events.length, new Error('terminated'));
222
- await runTurnHarness(session, stream);
223
- expect(session.turnInterrupted).toBe(true);
224
- const assistant = session.messages[1];
225
- const content = assistant.content;
226
- expect(content).toHaveLength(2);
227
- expect(content[1].type).toBe('text');
228
- // All 1000 paragraphs landed on session.messages — nothing lost.
229
- expect(content[1].text).toContain('paragraph 0:');
230
- expect(content[1].text).toContain('paragraph 500:');
231
- expect(content[1].text).toContain('paragraph 999:');
232
- expect(content[1].text.length).toBeGreaterThan(150_000);
233
- });
234
- it('produces a session that "continue" can read — prior context is intact for the next turn', async () => {
235
- const session = newSession('continue-1', 'S4H', 'abap-cca', 'claude-opus-4-7');
236
- session.messages.push({ role: 'user', content: '/abap-cca on ZDPR_PAYMENTS' });
237
- const events = [
238
- { type: 'content_block_start', content_block: { type: 'text', text: '' } },
239
- { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Findings so far: 13 objects, 5 years stale.' } },
240
- ];
241
- const stream = makeFakeStream(events, events.length, new Error('terminated'));
242
- await runTurnHarness(session, stream);
243
- // Now simulate the "continue" turn: load the session, push a user
244
- // message, verify the model would see all prior context.
245
- const reloaded = await loadSession('continue-1');
246
- reloaded.messages.push({ role: 'user', content: 'continue and emit the full report' });
247
- // The model's input is reloaded.messages — verify it contains the full
248
- // prior context: original prompt + the partial assistant findings.
249
- expect(reloaded.messages).toHaveLength(3);
250
- expect(reloaded.messages[0]?.content).toBe('/abap-cca on ZDPR_PAYMENTS');
251
- expect((reloaded.messages[1]?.content)[0].text).toContain('5 years stale');
252
- expect(reloaded.messages[2]?.content).toBe('continue and emit the full report');
253
- });
254
- });
@@ -1,188 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- // Mock @inquirer/prompts so the test never blocks on stdin. The save hook's
3
- // `prompt` callback wraps inquirer's `input`, but the runSaveCommand mock
4
- // below short-circuits before that callback fires — this is just defence
5
- // in depth in case the gating logic ever regresses.
6
- vi.mock('@inquirer/prompts', () => ({
7
- input: vi.fn(async () => 'n'),
8
- }));
9
- // Mock the projects barrel so we can spy on runSaveCommand calls without
10
- // touching the filesystem or invoking the real extract/save pipeline.
11
- // vi.hoisted is required because vi.mock factories run before module-level
12
- // initialisers, so a plain `const runSaveCommandMock = vi.fn(...)` outside
13
- // of hoisted() would be undefined inside the factory.
14
- const { runSaveCommandMock } = vi.hoisted(() => ({
15
- runSaveCommandMock: vi.fn(async () => null),
16
- }));
17
- vi.mock('../../projects/index.js', () => ({
18
- runSaveCommand: runSaveCommandMock,
19
- }));
20
- // Import AFTER mocks are registered. ESM module evaluation order matters —
21
- // imports above must resolve to the mocks, not the real modules.
22
- import { maybeOfferSave, getAuthorIdentity } from '../loop.js';
23
- describe('getAuthorIdentity', () => {
24
- const ORIG_ENV = { ...process.env };
25
- beforeEach(() => {
26
- delete process.env.CSPEACH_AUTHOR_NAME;
27
- delete process.env.USER;
28
- delete process.env.USERNAME;
29
- });
30
- afterEach(() => {
31
- process.env = { ...ORIG_ENV };
32
- });
33
- it('prefers CSPEACH_AUTHOR_NAME when set', () => {
34
- process.env.CSPEACH_AUTHOR_NAME = 'Laeeq';
35
- process.env.USER = 'someone-else';
36
- expect(getAuthorIdentity()).toEqual({ name: 'Laeeq', role: 'consultant' });
37
- });
38
- it('falls back to USER, then USERNAME', () => {
39
- process.env.USERNAME = 'win-user';
40
- expect(getAuthorIdentity()).toEqual({ name: 'win-user', role: 'consultant' });
41
- process.env.USER = 'unix-user';
42
- expect(getAuthorIdentity()).toEqual({ name: 'unix-user', role: 'consultant' });
43
- });
44
- it('returns "consultant" when no env vars are set', () => {
45
- expect(getAuthorIdentity()).toEqual({ name: 'consultant', role: 'consultant' });
46
- });
47
- });
48
- describe('maybeOfferSave', () => {
49
- beforeEach(() => {
50
- runSaveCommandMock.mockClear();
51
- });
52
- it('invokes runSaveCommand for abap-spec-gap with non-empty text', async () => {
53
- const emit = vi.fn();
54
- await maybeOfferSave({
55
- skill: 'abap-spec-gap',
56
- assistantText: '## Found 3 gaps...\n- Q1: ...\n',
57
- userMessage: 'Build a customer aging report',
58
- tokensUsed: 1234,
59
- model: 'claude-opus-4-7',
60
- emit,
61
- });
62
- expect(runSaveCommandMock).toHaveBeenCalledTimes(1);
63
- const args = runSaveCommandMock.mock.calls[0][0];
64
- expect(args.skillName).toBe('abap-spec-gap');
65
- expect(args.skillOutput).toBe('## Found 3 gaps...\n- Q1: ...\n');
66
- expect(args.skillInput).toBe('Build a customer aging report');
67
- expect(args.tokensUsed).toBe(1234);
68
- expect(args.model).toBe('claude-opus-4-7');
69
- expect(args.skillVersion).toBe('1.0');
70
- expect(args.author).toEqual({
71
- name: expect.any(String),
72
- role: 'consultant',
73
- });
74
- expect(typeof args.prompt).toBe('function');
75
- expect(typeof args.log).toBe('function');
76
- expect(typeof args.cwd).toBe('string');
77
- });
78
- it('does NOT invoke runSaveCommand for non-spec-gap skills', async () => {
79
- const emit = vi.fn();
80
- await maybeOfferSave({
81
- skill: 'abap-radar',
82
- assistantText: 'Some lengthy radar output',
83
- userMessage: 'analyze ZCL_FOO',
84
- tokensUsed: 100,
85
- model: 'm',
86
- emit,
87
- });
88
- expect(runSaveCommandMock).not.toHaveBeenCalled();
89
- });
90
- it('does NOT invoke runSaveCommand when assistantText is empty', async () => {
91
- const emit = vi.fn();
92
- await maybeOfferSave({
93
- skill: 'abap-spec-gap',
94
- assistantText: '',
95
- userMessage: 'something',
96
- tokensUsed: 0,
97
- model: 'm',
98
- emit,
99
- });
100
- expect(runSaveCommandMock).not.toHaveBeenCalled();
101
- });
102
- it('does NOT invoke runSaveCommand when assistantText is whitespace only', async () => {
103
- const emit = vi.fn();
104
- await maybeOfferSave({
105
- skill: 'abap-spec-gap',
106
- assistantText: ' \n\n ',
107
- userMessage: 'something',
108
- tokensUsed: 0,
109
- model: 'm',
110
- emit,
111
- });
112
- expect(runSaveCommandMock).not.toHaveBeenCalled();
113
- });
114
- it('swallows errors from runSaveCommand and emits a [save] warning', async () => {
115
- runSaveCommandMock.mockRejectedValueOnce(new Error('parse blew up'));
116
- const emit = vi.fn();
117
- await expect(maybeOfferSave({
118
- skill: 'abap-spec-gap',
119
- assistantText: '## gaps',
120
- userMessage: 'x',
121
- tokensUsed: 1,
122
- model: 'm',
123
- emit,
124
- })).resolves.toBeUndefined();
125
- const emitted = emit.mock.calls.flat().join('\n');
126
- expect(emitted).toContain('[save]');
127
- expect(emitted).toContain('parse blew up');
128
- });
129
- // v0.5: design + estimate join the save-hook list
130
- it('invokes runSaveCommand for abap-design', async () => {
131
- const emit = vi.fn();
132
- await maybeOfferSave({
133
- skill: 'abap-design',
134
- assistantText: '## ABAP Design — X\n\n### Objects to Create\n| # | Name | Type | Purpose | Transport |\n|---|---|---|---|---|\n| 1 | ZCL_X | CLAS | x | $TMP |\n',
135
- userMessage: 'design X',
136
- tokensUsed: 5000,
137
- model: 'm',
138
- emit,
139
- });
140
- expect(runSaveCommandMock).toHaveBeenCalledTimes(1);
141
- expect(runSaveCommandMock.mock.calls[0][0].skillName).toBe('abap-design');
142
- });
143
- it('invokes runSaveCommand for abap-estimate', async () => {
144
- const emit = vi.fn();
145
- await maybeOfferSave({
146
- skill: 'abap-estimate',
147
- assistantText: '## Effort Estimate — X\n\n### Component Breakdown\n| # | Component | Objects | Optimistic (h) | Realistic (h) | Pessimistic (h) | Notes |\n|---|---|---|---|---|---|---|\n| 1 | x | y | 1 | 2 | 3 | n |\n',
148
- userMessage: 'estimate X',
149
- tokensUsed: 4000,
150
- model: 'm',
151
- emit,
152
- });
153
- expect(runSaveCommandMock).toHaveBeenCalledTimes(1);
154
- expect(runSaveCommandMock.mock.calls[0][0].skillName).toBe('abap-estimate');
155
- });
156
- it('threads promotedFrom into runSaveCommand when supplied', async () => {
157
- const emit = vi.fn();
158
- const promotedFrom = {
159
- id: 'src-uuid', title: 'Src', artefactType: 'spec-gap', version: 3,
160
- sha256: 'a'.repeat(64), snapshottedAt: '2026-05-05T10:00:00Z',
161
- snapshot: { items: [{ id: 'gap-001', text: 'Q', severity: 'BLOCKER', status: 'answered', answer: 'A' }] },
162
- };
163
- await maybeOfferSave({
164
- skill: 'abap-design',
165
- assistantText: '## ABAP Design — X',
166
- userMessage: 'x',
167
- tokensUsed: 1,
168
- model: 'm',
169
- emit,
170
- promotedFrom,
171
- });
172
- const args = runSaveCommandMock.mock.calls[0][0];
173
- expect(args.promotedFrom).toEqual(promotedFrom);
174
- });
175
- it('passes promotedFrom: null when omitted (non-promoted runs)', async () => {
176
- const emit = vi.fn();
177
- await maybeOfferSave({
178
- skill: 'abap-spec-gap',
179
- assistantText: '## Gaps',
180
- userMessage: 'x',
181
- tokensUsed: 1,
182
- model: 'm',
183
- emit,
184
- });
185
- const args = runSaveCommandMock.mock.calls[0][0];
186
- expect(args.promotedFrom).toBeNull();
187
- });
188
- });