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