@cspeach/cli 0.6.4 → 0.6.5

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 (107) hide show
  1. package/dist/index.js +0 -0
  2. package/package.json +86 -82
  3. package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
  4. package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
  5. package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
  6. package/dist/agent/__tests__/project-context-injection.test.js +0 -161
  7. package/dist/agent/__tests__/repair-partial.test.js +0 -79
  8. package/dist/agent/__tests__/retry-key.test.js +0 -77
  9. package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
  10. package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
  11. package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
  12. package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
  13. package/dist/agent/__tests__/turn-stream.test.js +0 -100
  14. package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
  15. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
  16. package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
  17. package/dist/agent/providers/__tests__/factory.test.js +0 -33
  18. package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
  19. package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
  20. package/dist/approvals/__tests__/advisory-render.test.js +0 -54
  21. package/dist/auth/__tests__/auth-file.test.js +0 -42
  22. package/dist/auth/__tests__/me.test.js +0 -43
  23. package/dist/classifier/__tests__/client.test.js +0 -70
  24. package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
  25. package/dist/commands/__tests__/login.test.js +0 -152
  26. package/dist/commands/__tests__/logout.test.js +0 -39
  27. package/dist/commands/__tests__/project-context-impact.test.js +0 -173
  28. package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
  29. package/dist/commands/__tests__/whoami.test.js +0 -91
  30. package/dist/config/__tests__/llm-config.test.js +0 -28
  31. package/dist/config/__tests__/shell-exec-config.test.js +0 -76
  32. package/dist/config/__tests__/write-mode.test.js +0 -36
  33. package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
  34. package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
  35. package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
  36. package/dist/project-context/__tests__/conventions.test.js +0 -221
  37. package/dist/project-context/__tests__/detect.test.js +0 -177
  38. package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
  39. package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
  40. package/dist/project-context/__tests__/domain-cap.test.js +0 -168
  41. package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
  42. package/dist/project-context/__tests__/git.test.js +0 -131
  43. package/dist/project-context/__tests__/index-files.test.js +0 -195
  44. package/dist/project-context/__tests__/index.test.js +0 -133
  45. package/dist/project-context/__tests__/render.test.js +0 -322
  46. package/dist/project-context/__tests__/types.test.js +0 -73
  47. package/dist/projects/__tests__/build.test.js +0 -52
  48. package/dist/projects/__tests__/canonicalize.test.js +0 -47
  49. package/dist/projects/__tests__/email-template.test.js +0 -71
  50. package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
  51. package/dist/projects/__tests__/extract-cca.test.js +0 -141
  52. package/dist/projects/__tests__/extract-design.test.js +0 -62
  53. package/dist/projects/__tests__/extract-estimate.test.js +0 -58
  54. package/dist/projects/__tests__/extract-modernize.test.js +0 -127
  55. package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
  56. package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
  57. package/dist/projects/__tests__/filename.test.js +0 -32
  58. package/dist/projects/__tests__/promote-command.test.js +0 -170
  59. package/dist/projects/__tests__/promote.test.js +0 -181
  60. package/dist/projects/__tests__/save-command.test.js +0 -216
  61. package/dist/projects/__tests__/save.test.js +0 -41
  62. package/dist/projects/__tests__/status.test.js +0 -208
  63. package/dist/projects/__tests__/types.test.js +0 -41
  64. package/dist/projects/__tests__/validate.test.js +0 -165
  65. package/dist/projects/__tests__/workspace.test.js +0 -337
  66. package/dist/repl/__tests__/current-transport.test.js +0 -43
  67. package/dist/repl/__tests__/diff-display.test.js +0 -59
  68. package/dist/repl/__tests__/file-picker.test.js +0 -97
  69. package/dist/repl/__tests__/rule8-detector.test.js +0 -114
  70. package/dist/repl/__tests__/safety-confirm.test.js +0 -130
  71. package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
  72. package/dist/sap/__tests__/system-info.test.js +0 -344
  73. package/dist/session/__tests__/store-discovery.test.js +0 -114
  74. package/dist/session/__tests__/time-ago.test.js +0 -53
  75. package/dist/skills/__tests__/canonical.test.js +0 -20
  76. package/dist/skills/__tests__/manifest-client.test.js +0 -88
  77. package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
  78. package/dist/skills/__tests__/source-managed.test.js +0 -60
  79. package/dist/tools/__tests__/_background-shared.test.js +0 -69
  80. package/dist/tools/__tests__/_command-shared.test.js +0 -59
  81. package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
  82. package/dist/tools/__tests__/_flag.test.js +0 -66
  83. package/dist/tools/__tests__/_helpers.js +0 -17
  84. package/dist/tools/__tests__/_web-shared.test.js +0 -143
  85. package/dist/tools/__tests__/agent_run.test.js +0 -166
  86. package/dist/tools/__tests__/approval-advisory.test.js +0 -66
  87. package/dist/tools/__tests__/background_run.test.js +0 -113
  88. package/dist/tools/__tests__/convention_get.test.js +0 -53
  89. package/dist/tools/__tests__/file-edit.test.js +0 -72
  90. package/dist/tools/__tests__/file-read.test.js +0 -81
  91. package/dist/tools/__tests__/file-write.test.js +0 -68
  92. package/dist/tools/__tests__/glob.test.js +0 -128
  93. package/dist/tools/__tests__/grep.test.js +0 -71
  94. package/dist/tools/__tests__/monitor_emit.test.js +0 -96
  95. package/dist/tools/__tests__/playbook_get.test.js +0 -109
  96. package/dist/tools/__tests__/project_context_get.test.js +0 -71
  97. package/dist/tools/__tests__/registry-category.test.js +0 -60
  98. package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
  99. package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
  100. package/dist/tools/__tests__/schedule_create.test.js +0 -85
  101. package/dist/tools/__tests__/shell_exec.test.js +0 -135
  102. package/dist/tools/__tests__/transport-safety.test.js +0 -106
  103. package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
  104. package/dist/tools/__tests__/web_fetch.test.js +0 -329
  105. package/dist/tools/__tests__/web_search.test.js +0 -215
  106. package/dist/tools/__tests__/write-mode.test.js +0 -32
  107. package/dist/ui/__tests__/login-banner.test.js +0 -69
package/dist/index.js CHANGED
File without changes
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.6.5",
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
- });