@cspeach/cli 0.6.2 → 0.6.4

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 (108) hide show
  1. package/README.md +116 -108
  2. package/dist/agent/__tests__/crash-recover.integration.test.js +254 -0
  3. package/dist/agent/__tests__/loop-save-hook.test.js +188 -0
  4. package/dist/agent/__tests__/maybe-build-project-context.test.js +247 -0
  5. package/dist/agent/__tests__/project-context-injection.test.js +161 -0
  6. package/dist/agent/__tests__/repair-partial.test.js +79 -0
  7. package/dist/agent/__tests__/retry-key.test.js +77 -0
  8. package/dist/agent/__tests__/sap-connection-adapter.test.js +132 -0
  9. package/dist/agent/__tests__/skill-checkpoint.test.js +154 -0
  10. package/dist/agent/__tests__/turn-assistant-text.test.js +73 -0
  11. package/dist/agent/__tests__/turn-error-ux.test.js +145 -0
  12. package/dist/agent/__tests__/turn-stream.test.js +100 -0
  13. package/dist/agent/__tests__/turn-watchdog.test.js +126 -0
  14. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +30 -0
  15. package/dist/agent/providers/__tests__/byok-provider.test.js +32 -0
  16. package/dist/agent/providers/__tests__/factory.test.js +33 -0
  17. package/dist/agent/providers/__tests__/local-provider.test.js +55 -0
  18. package/dist/approvals/__tests__/advisory-prompt.test.js +57 -0
  19. package/dist/approvals/__tests__/advisory-render.test.js +54 -0
  20. package/dist/auth/__tests__/auth-file.test.js +42 -0
  21. package/dist/auth/__tests__/me.test.js +43 -0
  22. package/dist/classifier/__tests__/client.test.js +70 -0
  23. package/dist/commands/__tests__/config-set-write-mode.test.js +14 -0
  24. package/dist/commands/__tests__/login.test.js +152 -0
  25. package/dist/commands/__tests__/logout.test.js +39 -0
  26. package/dist/commands/__tests__/project-context-impact.test.js +173 -0
  27. package/dist/commands/__tests__/spec-gap-status.test.js +31 -0
  28. package/dist/commands/__tests__/whoami.test.js +91 -0
  29. package/dist/config/__tests__/llm-config.test.js +28 -0
  30. package/dist/config/__tests__/shell-exec-config.test.js +76 -0
  31. package/dist/config/__tests__/write-mode.test.js +36 -0
  32. package/dist/doctor/__tests__/check-forge-rules.test.js +134 -0
  33. package/dist/doctor/__tests__/check-llm-mode.test.js +21 -0
  34. package/dist/doctor/__tests__/check-write-mode.test.js +34 -0
  35. package/dist/index.js +0 -0
  36. package/dist/project-context/__tests__/conventions.test.js +221 -0
  37. package/dist/project-context/__tests__/detect.test.js +177 -0
  38. package/dist/project-context/__tests__/domain-abap-cloud.test.js +119 -0
  39. package/dist/project-context/__tests__/domain-abapgit.test.js +161 -0
  40. package/dist/project-context/__tests__/domain-cap.test.js +168 -0
  41. package/dist/project-context/__tests__/domain-fiori.test.js +282 -0
  42. package/dist/project-context/__tests__/git.test.js +131 -0
  43. package/dist/project-context/__tests__/index-files.test.js +195 -0
  44. package/dist/project-context/__tests__/index.test.js +133 -0
  45. package/dist/project-context/__tests__/render.test.js +322 -0
  46. package/dist/project-context/__tests__/types.test.js +73 -0
  47. package/dist/projects/__tests__/build.test.js +52 -0
  48. package/dist/projects/__tests__/canonicalize.test.js +47 -0
  49. package/dist/projects/__tests__/email-template.test.js +71 -0
  50. package/dist/projects/__tests__/expand-text-attachments.test.js +105 -0
  51. package/dist/projects/__tests__/extract-cca.test.js +141 -0
  52. package/dist/projects/__tests__/extract-design.test.js +62 -0
  53. package/dist/projects/__tests__/extract-estimate.test.js +58 -0
  54. package/dist/projects/__tests__/extract-modernize.test.js +127 -0
  55. package/dist/projects/__tests__/extract-spec-gap.test.js +122 -0
  56. package/dist/projects/__tests__/extract-test-coverage.test.js +133 -0
  57. package/dist/projects/__tests__/filename.test.js +32 -0
  58. package/dist/projects/__tests__/promote-command.test.js +170 -0
  59. package/dist/projects/__tests__/promote.test.js +181 -0
  60. package/dist/projects/__tests__/save-command.test.js +216 -0
  61. package/dist/projects/__tests__/save.test.js +41 -0
  62. package/dist/projects/__tests__/status.test.js +208 -0
  63. package/dist/projects/__tests__/types.test.js +41 -0
  64. package/dist/projects/__tests__/validate.test.js +165 -0
  65. package/dist/projects/__tests__/workspace.test.js +337 -0
  66. package/dist/repl/__tests__/current-transport.test.js +43 -0
  67. package/dist/repl/__tests__/diff-display.test.js +59 -0
  68. package/dist/repl/__tests__/file-picker.test.js +97 -0
  69. package/dist/repl/__tests__/rule8-detector.test.js +114 -0
  70. package/dist/repl/__tests__/safety-confirm.test.js +130 -0
  71. package/dist/repl/__tests__/safety-mode-state.test.js +42 -0
  72. package/dist/sap/__tests__/system-info.test.js +344 -0
  73. package/dist/session/__tests__/store-discovery.test.js +114 -0
  74. package/dist/session/__tests__/time-ago.test.js +53 -0
  75. package/dist/skills/__tests__/canonical.test.js +20 -0
  76. package/dist/skills/__tests__/manifest-client.test.js +88 -0
  77. package/dist/skills/__tests__/promotion-dispatch.test.js +28 -0
  78. package/dist/skills/__tests__/source-managed.test.js +60 -0
  79. package/dist/tools/__tests__/_background-shared.test.js +69 -0
  80. package/dist/tools/__tests__/_command-shared.test.js +59 -0
  81. package/dist/tools/__tests__/_filesystem-shared.test.js +42 -0
  82. package/dist/tools/__tests__/_flag.test.js +66 -0
  83. package/dist/tools/__tests__/_helpers.js +17 -0
  84. package/dist/tools/__tests__/_web-shared.test.js +143 -0
  85. package/dist/tools/__tests__/agent_run.test.js +166 -0
  86. package/dist/tools/__tests__/approval-advisory.test.js +66 -0
  87. package/dist/tools/__tests__/background_run.test.js +113 -0
  88. package/dist/tools/__tests__/convention_get.test.js +53 -0
  89. package/dist/tools/__tests__/file-edit.test.js +72 -0
  90. package/dist/tools/__tests__/file-read.test.js +81 -0
  91. package/dist/tools/__tests__/file-write.test.js +68 -0
  92. package/dist/tools/__tests__/glob.test.js +128 -0
  93. package/dist/tools/__tests__/grep.test.js +71 -0
  94. package/dist/tools/__tests__/monitor_emit.test.js +96 -0
  95. package/dist/tools/__tests__/playbook_get.test.js +109 -0
  96. package/dist/tools/__tests__/project_context_get.test.js +71 -0
  97. package/dist/tools/__tests__/registry-category.test.js +60 -0
  98. package/dist/tools/__tests__/sap-read-extras.test.js +196 -0
  99. package/dist/tools/__tests__/sap-write-extras.test.js +573 -0
  100. package/dist/tools/__tests__/schedule_create.test.js +85 -0
  101. package/dist/tools/__tests__/shell_exec.test.js +135 -0
  102. package/dist/tools/__tests__/transport-safety.test.js +106 -0
  103. package/dist/tools/__tests__/update-method-intercept.test.js +128 -0
  104. package/dist/tools/__tests__/web_fetch.test.js +329 -0
  105. package/dist/tools/__tests__/web_search.test.js +215 -0
  106. package/dist/tools/__tests__/write-mode.test.js +32 -0
  107. package/dist/ui/__tests__/login-banner.test.js +69 -0
  108. package/package.json +82 -83
package/README.md CHANGED
@@ -1,108 +1,116 @@
1
- # CSPeach
2
-
3
- AI-assisted ABAP development CLI. 34 skills, direct SAP access, safety
4
- gates the model cannot bypass.
5
-
6
- ## Install
7
-
8
- ```bash
9
- npm install -g @cspeach/cli
10
- ```
11
-
12
- Requires Node.js 20+. After install:
13
-
14
- ```bash
15
- cspeach login # opens browser, authorizes this machine
16
- cspeach config add-sap
17
- cspeach # interactive REPL
18
- ```
19
-
20
- ## What it does
21
-
22
- CSPeach runs an AI consultant against your own SAP. Pick a skill (or type a
23
- prompt — the classifier picks one for you):
24
-
25
- ```text
26
- /abap-spec-gap find missing questions before any code is written
27
- /abap-design object list + dependency DAG
28
- /abap-estimate component-level hours with optimistic / realistic /
29
- pessimistic ranges
30
- /abap-cca estate-wide custom-code analysis for S/4HANA
31
- /abap-upgrade-scan baseline scan against the readiness variant
32
- /abap-upgrade-fix interactive fix loop with per-object approval
33
- /abap-incident short dump → fix → verify → handover
34
- cspeach --help full skill list (34 total)
35
- ```
36
-
37
- Every turn runs locally on your machine. SAP source and SAP data flow through
38
- `api.cspeach.dev` to the LLM. The proxy logs request metadata (skill, model,
39
- token counts, latency) for billing; the message body is forwarded to
40
- Anthropic and not stored on the proxy. SAP credentials never leave your
41
- machine.
42
-
43
- ## Safety
44
-
45
- - **Snapshot gate** — every write auto-snapshots first
46
- - **Approval gate** — mutating tools require a single-use JWT bound to
47
- (object, operation); the model sees a plan, you see each change
48
- - **Verify gate** — every write is syntax-checked; activation blocked on errors
49
- - **Transport discipline** — all writes land in your session transport; `$TMP`
50
- is opt-in
51
-
52
- Ten Forge Rules are non-negotiable. Full philosophy in the `CSPeach Principles`
53
- preamble shown to the model every turn.
54
-
55
- ## Configuration
56
-
57
- `~/.cspeach/config.toml` is created on first `cspeach config add-sap`:
58
-
59
- ```toml
60
- proxy_url = "https://api.cspeach.dev"
61
- default_model = "claude-opus-4-7"
62
-
63
- [sap.S4H-DEV]
64
- host = "sap-dev.company.com"
65
- port = 44300
66
- client = "100"
67
- useSsl = true
68
- username = "S.LAEEQ"
69
- ```
70
-
71
- SAP passwords are stored in your OS keychain.
72
-
73
- ## Common commands
74
-
75
- ```text
76
- cspeach login sign in to api.cspeach.dev
77
- cspeach whoami show signed-in identity + plan/trial
78
- cspeach logout clear local auth
79
- cspeach config add-sap add a SAP system
80
- cspeach doctor diagnose install + connectivity
81
- cspeach --resume resume a prior session
82
- cspeach --upgrade pull the latest CLI release
83
- cspeach --version
84
- ```
85
-
86
- ## Troubleshooting
87
-
88
- If `cspeach doctor` fails:
89
-
90
- - **`auth`** — run `cspeach login` again
91
- - **`keychain`** — Windows Credential Manager / libsecret missing; CSPeach
92
- falls back to per-session prompts, slower but functional
93
- - **`sap`** — host unreachable; check VPN + the host/port in config
94
-
95
- If the REPL hangs on first prompt, check `https://api.cspeach.dev/health`.
96
-
97
- ## Links
98
-
99
- - **Landing**: https://cspeach.dev
100
- - **Contact**: laeeq.siddique@cremencing.com
101
- - **LinkedIn**: @laeeqsiddique
102
-
103
- ## License
104
-
105
- Proprietary. Copyright (c) 2026 Cremencing Solutions. All rights reserved.
106
- Free to use under your active [CSPeach plan](https://cspeach.dev); not free
107
- to redistribute, fork, or sublicense. Commercial / OEM licensing:
108
- laeeq.siddique@cremencing.com.
1
+ # CSPeach
2
+
3
+ AI-assisted ABAP development CLI. 34 skills, direct SAP access, safety
4
+ gates the model cannot bypass.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install -g @cspeach/cli
10
+ ```
11
+
12
+ Requires Node.js 20+. After install:
13
+
14
+ ```bash
15
+ cspeach login # opens browser, authorizes this machine
16
+ cspeach config add-sap
17
+ cspeach # interactive REPL
18
+ ```
19
+
20
+ ## What it does
21
+
22
+ CSPeach runs an AI consultant against your own SAP. Pick a skill (or type a
23
+ prompt — the classifier picks one for you):
24
+
25
+ ```text
26
+ /abap-spec-gap find missing questions before any code is written
27
+ /abap-design object list + dependency DAG
28
+ /abap-estimate component-level hours with optimistic / realistic /
29
+ pessimistic ranges
30
+ /abap-cca estate-wide custom-code analysis for S/4HANA
31
+ /abap-upgrade-scan baseline scan against the readiness variant
32
+ /abap-upgrade-fix interactive fix loop with per-object approval
33
+ /abap-incident short dump → fix → verify → handover
34
+ cspeach --help full skill list (34 total)
35
+ ```
36
+
37
+ Every turn runs locally on your machine. SAP source and SAP data flow through
38
+ `api.cspeach.dev` to the LLM. The proxy logs request metadata (skill, model,
39
+ token counts, latency) for billing; the message body is forwarded to
40
+ Anthropic and not stored on the proxy. SAP credentials never leave your
41
+ machine.
42
+
43
+ ## Safety
44
+
45
+ - **Snapshot gate** — every SAP write auto-snapshots first
46
+ - **SAP approval gate** — SAP-write and transport tools (`sap_set_source`,
47
+ `sap_create_object`, `sap_delete_object`, `sap_update_method`,
48
+ `sap_activate`, `sap_transport_*`) require a single-use JWT bound to
49
+ (object, operation); the model sees a plan, you approve each change
50
+ - **Optional tools default OFF** — filesystem, shell, web, and subagent
51
+ tools are invisible to the model unless you set `CSPEACH_TOOL_<NAME>=on`
52
+ in the environment
53
+ - **Batch-write interrupt** — any second mutating call in a single turn
54
+ (Rule 8, any tool category) pauses for an explicit yes/no before it runs
55
+ - **Verify gate** — every SAP write is syntax-checked; activation blocked on
56
+ errors
57
+ - **Transport discipline** — all SAP writes land in your session transport;
58
+ `$TMP` is opt-in
59
+
60
+ Ten Forge Rules are non-negotiable. Full philosophy in the `CSPeach Principles`
61
+ preamble shown to the model every turn.
62
+
63
+ ## Configuration
64
+
65
+ `~/.cspeach/config.toml` is created on first `cspeach config add-sap`:
66
+
67
+ ```toml
68
+ proxy_url = "https://api.cspeach.dev"
69
+ default_model = "claude-opus-4-7"
70
+
71
+ [sap.S4H-DEV]
72
+ host = "sap-dev.company.com"
73
+ port = 44300
74
+ client = "100"
75
+ useSsl = true
76
+ username = "S.LAEEQ"
77
+ ```
78
+
79
+ SAP passwords are stored in your OS keychain.
80
+
81
+ ## Common commands
82
+
83
+ ```text
84
+ cspeach login sign in to api.cspeach.dev
85
+ cspeach whoami show signed-in identity + plan/trial
86
+ cspeach logout clear local auth
87
+ cspeach config add-sap add a SAP system
88
+ cspeach doctor diagnose install + connectivity
89
+ cspeach --resume resume a prior session
90
+ cspeach --upgrade pull the latest CLI release
91
+ cspeach --version
92
+ ```
93
+
94
+ ## Troubleshooting
95
+
96
+ If `cspeach doctor` fails:
97
+
98
+ - **`auth`** — run `cspeach login` again
99
+ - **`keychain`** — Windows Credential Manager / libsecret missing; CSPeach
100
+ falls back to per-session prompts, slower but functional
101
+ - **`sap`** — host unreachable; check VPN + the host/port in config
102
+
103
+ If the REPL hangs on first prompt, check `https://api.cspeach.dev/health`.
104
+
105
+ ## Links
106
+
107
+ - **Landing**: https://cspeach.dev
108
+ - **Contact**: laeeq.siddique@cremencing.com
109
+ - **LinkedIn**: @laeeqsiddique
110
+
111
+ ## License
112
+
113
+ Proprietary. Copyright (c) 2026 Cremencing Solutions. All rights reserved.
114
+ Free to use under your active [CSPeach plan](https://cspeach.dev); not free
115
+ to redistribute, fork, or sublicense. Commercial / OEM licensing:
116
+ laeeq.siddique@cremencing.com.
@@ -0,0 +1,254 @@
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
+ });
@@ -0,0 +1,188 @@
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
+ });