@cspeach/cli 0.6.3 → 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.
- package/dist/agent/__tests__/crash-recover.integration.test.js +254 -0
- package/dist/agent/__tests__/loop-save-hook.test.js +188 -0
- package/dist/agent/__tests__/maybe-build-project-context.test.js +247 -0
- package/dist/agent/__tests__/project-context-injection.test.js +161 -0
- package/dist/agent/__tests__/repair-partial.test.js +79 -0
- package/dist/agent/__tests__/retry-key.test.js +77 -0
- package/dist/agent/__tests__/sap-connection-adapter.test.js +132 -0
- package/dist/agent/__tests__/skill-checkpoint.test.js +154 -0
- package/dist/agent/__tests__/turn-assistant-text.test.js +73 -0
- package/dist/agent/__tests__/turn-error-ux.test.js +145 -0
- package/dist/agent/__tests__/turn-stream.test.js +100 -0
- package/dist/agent/__tests__/turn-watchdog.test.js +126 -0
- package/dist/agent/providers/__tests__/ai-hub-provider.test.js +30 -0
- package/dist/agent/providers/__tests__/byok-provider.test.js +32 -0
- package/dist/agent/providers/__tests__/factory.test.js +33 -0
- package/dist/agent/providers/__tests__/local-provider.test.js +55 -0
- package/dist/approvals/__tests__/advisory-prompt.test.js +57 -0
- package/dist/approvals/__tests__/advisory-render.test.js +54 -0
- package/dist/auth/__tests__/auth-file.test.js +42 -0
- package/dist/auth/__tests__/me.test.js +43 -0
- package/dist/classifier/__tests__/client.test.js +70 -0
- package/dist/commands/__tests__/config-set-write-mode.test.js +14 -0
- package/dist/commands/__tests__/login.test.js +152 -0
- package/dist/commands/__tests__/logout.test.js +39 -0
- package/dist/commands/__tests__/project-context-impact.test.js +173 -0
- package/dist/commands/__tests__/spec-gap-status.test.js +31 -0
- package/dist/commands/__tests__/whoami.test.js +91 -0
- package/dist/config/__tests__/llm-config.test.js +28 -0
- package/dist/config/__tests__/shell-exec-config.test.js +76 -0
- package/dist/config/__tests__/write-mode.test.js +36 -0
- package/dist/doctor/__tests__/check-forge-rules.test.js +134 -0
- package/dist/doctor/__tests__/check-llm-mode.test.js +21 -0
- package/dist/doctor/__tests__/check-write-mode.test.js +34 -0
- package/dist/index.js +0 -0
- package/dist/project-context/__tests__/conventions.test.js +221 -0
- package/dist/project-context/__tests__/detect.test.js +177 -0
- package/dist/project-context/__tests__/domain-abap-cloud.test.js +119 -0
- package/dist/project-context/__tests__/domain-abapgit.test.js +161 -0
- package/dist/project-context/__tests__/domain-cap.test.js +168 -0
- package/dist/project-context/__tests__/domain-fiori.test.js +282 -0
- package/dist/project-context/__tests__/git.test.js +131 -0
- package/dist/project-context/__tests__/index-files.test.js +195 -0
- package/dist/project-context/__tests__/index.test.js +133 -0
- package/dist/project-context/__tests__/render.test.js +322 -0
- package/dist/project-context/__tests__/types.test.js +73 -0
- package/dist/projects/__tests__/build.test.js +52 -0
- package/dist/projects/__tests__/canonicalize.test.js +47 -0
- package/dist/projects/__tests__/email-template.test.js +71 -0
- package/dist/projects/__tests__/expand-text-attachments.test.js +105 -0
- package/dist/projects/__tests__/extract-cca.test.js +141 -0
- package/dist/projects/__tests__/extract-design.test.js +62 -0
- package/dist/projects/__tests__/extract-estimate.test.js +58 -0
- package/dist/projects/__tests__/extract-modernize.test.js +127 -0
- package/dist/projects/__tests__/extract-spec-gap.test.js +122 -0
- package/dist/projects/__tests__/extract-test-coverage.test.js +133 -0
- package/dist/projects/__tests__/filename.test.js +32 -0
- package/dist/projects/__tests__/promote-command.test.js +170 -0
- package/dist/projects/__tests__/promote.test.js +181 -0
- package/dist/projects/__tests__/save-command.test.js +216 -0
- package/dist/projects/__tests__/save.test.js +41 -0
- package/dist/projects/__tests__/status.test.js +208 -0
- package/dist/projects/__tests__/types.test.js +41 -0
- package/dist/projects/__tests__/validate.test.js +165 -0
- package/dist/projects/__tests__/workspace.test.js +337 -0
- package/dist/repl/__tests__/current-transport.test.js +43 -0
- package/dist/repl/__tests__/diff-display.test.js +59 -0
- package/dist/repl/__tests__/file-picker.test.js +97 -0
- package/dist/repl/__tests__/rule8-detector.test.js +114 -0
- package/dist/repl/__tests__/safety-confirm.test.js +130 -0
- package/dist/repl/__tests__/safety-mode-state.test.js +42 -0
- package/dist/sap/__tests__/system-info.test.js +344 -0
- package/dist/session/__tests__/store-discovery.test.js +114 -0
- package/dist/session/__tests__/time-ago.test.js +53 -0
- package/dist/skills/__tests__/canonical.test.js +20 -0
- package/dist/skills/__tests__/manifest-client.test.js +88 -0
- package/dist/skills/__tests__/promotion-dispatch.test.js +28 -0
- package/dist/skills/__tests__/source-managed.test.js +60 -0
- package/dist/tools/__tests__/_background-shared.test.js +69 -0
- package/dist/tools/__tests__/_command-shared.test.js +59 -0
- package/dist/tools/__tests__/_filesystem-shared.test.js +42 -0
- package/dist/tools/__tests__/_flag.test.js +66 -0
- package/dist/tools/__tests__/_helpers.js +17 -0
- package/dist/tools/__tests__/_web-shared.test.js +143 -0
- package/dist/tools/__tests__/agent_run.test.js +166 -0
- package/dist/tools/__tests__/approval-advisory.test.js +66 -0
- package/dist/tools/__tests__/background_run.test.js +113 -0
- package/dist/tools/__tests__/convention_get.test.js +53 -0
- package/dist/tools/__tests__/file-edit.test.js +72 -0
- package/dist/tools/__tests__/file-read.test.js +81 -0
- package/dist/tools/__tests__/file-write.test.js +68 -0
- package/dist/tools/__tests__/glob.test.js +128 -0
- package/dist/tools/__tests__/grep.test.js +71 -0
- package/dist/tools/__tests__/monitor_emit.test.js +96 -0
- package/dist/tools/__tests__/playbook_get.test.js +109 -0
- package/dist/tools/__tests__/project_context_get.test.js +71 -0
- package/dist/tools/__tests__/registry-category.test.js +60 -0
- package/dist/tools/__tests__/sap-read-extras.test.js +196 -0
- package/dist/tools/__tests__/sap-write-extras.test.js +573 -0
- package/dist/tools/__tests__/schedule_create.test.js +85 -0
- package/dist/tools/__tests__/shell_exec.test.js +135 -0
- package/dist/tools/__tests__/transport-safety.test.js +106 -0
- package/dist/tools/__tests__/update-method-intercept.test.js +128 -0
- package/dist/tools/__tests__/web_fetch.test.js +329 -0
- package/dist/tools/__tests__/web_search.test.js +215 -0
- package/dist/tools/__tests__/write-mode.test.js +32 -0
- package/dist/ui/__tests__/login-banner.test.js +69 -0
- package/package.json +82 -83
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildSapConnectionContext } from '../sap-connection-adapter.js';
|
|
3
|
+
const onPremS4 = {
|
|
4
|
+
alias: 'S4H',
|
|
5
|
+
platform: 'S/4HANA',
|
|
6
|
+
release: 'S/4HANA 2023',
|
|
7
|
+
abapVersion: '7.58',
|
|
8
|
+
deployment: 'on-prem-or-private-cloud',
|
|
9
|
+
components: { SAP_BASIS: '758' },
|
|
10
|
+
queriedAt: '2026-05-10T10:00:00Z',
|
|
11
|
+
};
|
|
12
|
+
const cloudInfo = {
|
|
13
|
+
alias: 'CLOUD',
|
|
14
|
+
platform: 'S/4HANA',
|
|
15
|
+
release: 'S/4HANA Cloud, public 2402',
|
|
16
|
+
abapVersion: 'cloud',
|
|
17
|
+
deployment: 'public-cloud',
|
|
18
|
+
components: { SAP_BASIS: '780' },
|
|
19
|
+
queriedAt: '2026-05-10T10:00:00Z',
|
|
20
|
+
};
|
|
21
|
+
describe('buildSapConnectionContext — alias + null system info', () => {
|
|
22
|
+
it('returns a context with the alias and unknown system summary', () => {
|
|
23
|
+
const ctx = buildSapConnectionContext('S4H', null);
|
|
24
|
+
expect(ctx.alias).toBe('S4H');
|
|
25
|
+
expect(ctx.system).toEqual({
|
|
26
|
+
sid: null,
|
|
27
|
+
release: null,
|
|
28
|
+
abapVersion: null,
|
|
29
|
+
platform: 'unknown',
|
|
30
|
+
host: null,
|
|
31
|
+
});
|
|
32
|
+
expect(ctx.currentTransport).toBeNull();
|
|
33
|
+
expect(ctx.activeScope).toBeNull();
|
|
34
|
+
expect(ctx.recentObjects).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('buildSapConnectionContext — system info mapping', () => {
|
|
38
|
+
it('maps on-prem deployment + release + abapVersion', () => {
|
|
39
|
+
const ctx = buildSapConnectionContext('S4H', onPremS4);
|
|
40
|
+
expect(ctx.system).toMatchObject({
|
|
41
|
+
release: 'S/4HANA 2023',
|
|
42
|
+
abapVersion: '7.58',
|
|
43
|
+
platform: 'on-premise',
|
|
44
|
+
sid: null,
|
|
45
|
+
host: null,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
it('maps public-cloud deployment to platform=cloud', () => {
|
|
49
|
+
const ctx = buildSapConnectionContext('CLOUD', cloudInfo);
|
|
50
|
+
expect(ctx.system.platform).toBe('cloud');
|
|
51
|
+
expect(ctx.system.release).toBe('S/4HANA Cloud, public 2402');
|
|
52
|
+
});
|
|
53
|
+
it('falls back to platform=unknown for unknown deployment', () => {
|
|
54
|
+
const info = { ...onPremS4, deployment: 'unknown' };
|
|
55
|
+
const ctx = buildSapConnectionContext('X', info);
|
|
56
|
+
expect(ctx.system.platform).toBe('unknown');
|
|
57
|
+
});
|
|
58
|
+
it('coerces empty release / abapVersion to null', () => {
|
|
59
|
+
const info = { ...onPremS4, release: '', abapVersion: '' };
|
|
60
|
+
const ctx = buildSapConnectionContext('X', info);
|
|
61
|
+
expect(ctx.system.release).toBeNull();
|
|
62
|
+
expect(ctx.system.abapVersion).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('buildSapConnectionContext — state passthrough', () => {
|
|
66
|
+
it('passes through currentTransport when provided', () => {
|
|
67
|
+
const ctx = buildSapConnectionContext('S4H', onPremS4, {
|
|
68
|
+
currentTransport: 'S4HK900123',
|
|
69
|
+
});
|
|
70
|
+
expect(ctx.currentTransport).toBe('S4HK900123');
|
|
71
|
+
});
|
|
72
|
+
it('builds activeScope when activePackage is set', () => {
|
|
73
|
+
const ctx = buildSapConnectionContext('S4H', onPremS4, {
|
|
74
|
+
activePackage: 'ZFI_PAYMENTS',
|
|
75
|
+
});
|
|
76
|
+
expect(ctx.activeScope).toEqual({
|
|
77
|
+
package: 'ZFI_PAYMENTS',
|
|
78
|
+
object: null,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
it('builds activeScope when activeObject is set', () => {
|
|
82
|
+
const ctx = buildSapConnectionContext('S4H', onPremS4, {
|
|
83
|
+
activeObject: { name: 'ZCL_PAY', type: 'CLAS' },
|
|
84
|
+
});
|
|
85
|
+
expect(ctx.activeScope).toEqual({
|
|
86
|
+
package: null,
|
|
87
|
+
object: { name: 'ZCL_PAY', type: 'CLAS' },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
it('returns activeScope=null when neither package nor object is set', () => {
|
|
91
|
+
const ctx = buildSapConnectionContext('S4H', onPremS4, {});
|
|
92
|
+
expect(ctx.activeScope).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
it('passes through recentObjects (capped behavior is the renderer\'s job)', () => {
|
|
95
|
+
const refs = [
|
|
96
|
+
{ name: 'ZCL_PAY', type: 'CLAS', lastTouchedAt: '2026-05-10T10:00:00Z', lastAction: 'read' },
|
|
97
|
+
{ name: 'ZIF_PAY', type: 'INTF', lastTouchedAt: '2026-05-10T10:01:00Z', lastAction: 'write' },
|
|
98
|
+
];
|
|
99
|
+
const ctx = buildSapConnectionContext('S4H', onPremS4, { recentObjects: refs });
|
|
100
|
+
expect(ctx.recentObjects).toEqual(refs);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('buildSapConnectionContext — composes correctly with everything', () => {
|
|
104
|
+
it('full live-SAP context end-to-end', () => {
|
|
105
|
+
const ctx = buildSapConnectionContext('S4H', onPremS4, {
|
|
106
|
+
currentTransport: 'S4HK900123',
|
|
107
|
+
activePackage: 'ZFI_PAYMENTS',
|
|
108
|
+
activeObject: { name: 'ZCL_PAY', type: 'CLAS' },
|
|
109
|
+
recentObjects: [
|
|
110
|
+
{ name: 'ZCL_PAY', type: 'CLAS', lastTouchedAt: '2026-05-10T10:00:00Z', lastAction: 'read' },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
expect(ctx).toEqual({
|
|
114
|
+
alias: 'S4H',
|
|
115
|
+
system: {
|
|
116
|
+
sid: null,
|
|
117
|
+
release: 'S/4HANA 2023',
|
|
118
|
+
abapVersion: '7.58',
|
|
119
|
+
platform: 'on-premise',
|
|
120
|
+
host: null,
|
|
121
|
+
},
|
|
122
|
+
currentTransport: 'S4HK900123',
|
|
123
|
+
activeScope: {
|
|
124
|
+
package: 'ZFI_PAYMENTS',
|
|
125
|
+
object: { name: 'ZCL_PAY', type: 'CLAS' },
|
|
126
|
+
},
|
|
127
|
+
recentObjects: [
|
|
128
|
+
{ name: 'ZCL_PAY', type: 'CLAS', lastTouchedAt: '2026-05-10T10:00:00Z', lastAction: 'read' },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { applyToolResultCheckpoint, registerCheckpointHandler, checkpointsRoot, _resetHandlersForTesting, } from '../skill-checkpoint.js';
|
|
6
|
+
const ORIGINAL_HOME = os.homedir();
|
|
7
|
+
describe('applyToolResultCheckpoint (Layer 2)', () => {
|
|
8
|
+
let tempHome;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-checkpoint-'));
|
|
11
|
+
process.env.HOME = tempHome;
|
|
12
|
+
process.env.USERPROFILE = tempHome;
|
|
13
|
+
_resetHandlersForTesting();
|
|
14
|
+
});
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
process.env.HOME = ORIGINAL_HOME;
|
|
17
|
+
process.env.USERPROFILE = ORIGINAL_HOME;
|
|
18
|
+
await fs.rm(tempHome, { recursive: true, force: true });
|
|
19
|
+
_resetHandlersForTesting();
|
|
20
|
+
});
|
|
21
|
+
function makeInput(overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
sessionId: 'test-sess',
|
|
24
|
+
skill: 'abap-cca',
|
|
25
|
+
toolName: 'sap_sql_query',
|
|
26
|
+
args: { query: 'SELECT * FROM TADIR WHERE DEVCLASS = ?' },
|
|
27
|
+
result: '[{"OBJ_NAME":"ZFOO","OBJECT":"PROG"}]',
|
|
28
|
+
isError: false,
|
|
29
|
+
durationMs: 382,
|
|
30
|
+
completedAt: '2026-05-09T20:09:56.746Z',
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
it('appends a JSONL row to ~/.cspeach/checkpoints/<sessionId>/tool-calls.jsonl', async () => {
|
|
35
|
+
await applyToolResultCheckpoint(makeInput());
|
|
36
|
+
const filePath = path.join(checkpointsRoot(), 'test-sess', 'tool-calls.jsonl');
|
|
37
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
38
|
+
expect(content.trim().split('\n')).toHaveLength(1);
|
|
39
|
+
const row = JSON.parse(content.trim());
|
|
40
|
+
expect(row.tool).toBe('sap_sql_query');
|
|
41
|
+
expect(row.ok).toBe(true);
|
|
42
|
+
expect(row.duration_ms).toBe(382);
|
|
43
|
+
expect(row.at).toBe('2026-05-09T20:09:56.746Z');
|
|
44
|
+
});
|
|
45
|
+
it('appends multiple rows in order without overwriting', async () => {
|
|
46
|
+
await applyToolResultCheckpoint(makeInput({ toolName: 'sap_sql_query' }));
|
|
47
|
+
await applyToolResultCheckpoint(makeInput({ toolName: 'sap_search_object' }));
|
|
48
|
+
await applyToolResultCheckpoint(makeInput({ toolName: 'sap_atc_run' }));
|
|
49
|
+
const filePath = path.join(checkpointsRoot(), 'test-sess', 'tool-calls.jsonl');
|
|
50
|
+
const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n');
|
|
51
|
+
expect(lines).toHaveLength(3);
|
|
52
|
+
expect(JSON.parse(lines[0]).tool).toBe('sap_sql_query');
|
|
53
|
+
expect(JSON.parse(lines[1]).tool).toBe('sap_search_object');
|
|
54
|
+
expect(JSON.parse(lines[2]).tool).toBe('sap_atc_run');
|
|
55
|
+
});
|
|
56
|
+
it('captures errored tool calls with ok=false', async () => {
|
|
57
|
+
await applyToolResultCheckpoint(makeInput({
|
|
58
|
+
isError: true,
|
|
59
|
+
result: 'SAP HTTP 400: sqlQuery',
|
|
60
|
+
}));
|
|
61
|
+
const filePath = path.join(checkpointsRoot(), 'test-sess', 'tool-calls.jsonl');
|
|
62
|
+
const row = JSON.parse((await fs.readFile(filePath, 'utf-8')).trim());
|
|
63
|
+
expect(row.ok).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
it('truncates oversized result payloads to ~4 KB', async () => {
|
|
66
|
+
const huge = 'x'.repeat(10_000);
|
|
67
|
+
await applyToolResultCheckpoint(makeInput({ result: huge }));
|
|
68
|
+
const filePath = path.join(checkpointsRoot(), 'test-sess', 'tool-calls.jsonl');
|
|
69
|
+
const row = JSON.parse((await fs.readFile(filePath, 'utf-8')).trim());
|
|
70
|
+
expect(row.result.length).toBeLessThanOrEqual(4100);
|
|
71
|
+
expect(row.result).toMatch(/\.\.\.$/);
|
|
72
|
+
});
|
|
73
|
+
it('separates sessions into different directories', async () => {
|
|
74
|
+
await applyToolResultCheckpoint(makeInput({ sessionId: 'sess-a' }));
|
|
75
|
+
await applyToolResultCheckpoint(makeInput({ sessionId: 'sess-b' }));
|
|
76
|
+
const a = await fs.readFile(path.join(checkpointsRoot(), 'sess-a', 'tool-calls.jsonl'), 'utf-8');
|
|
77
|
+
const b = await fs.readFile(path.join(checkpointsRoot(), 'sess-b', 'tool-calls.jsonl'), 'utf-8');
|
|
78
|
+
expect(a.trim().split('\n')).toHaveLength(1);
|
|
79
|
+
expect(b.trim().split('\n')).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
it('runs even when no skill handler is registered (default JSONL is unconditional)', async () => {
|
|
82
|
+
await applyToolResultCheckpoint(makeInput({ skill: 'unknown-skill' }));
|
|
83
|
+
const filePath = path.join(checkpointsRoot(), 'test-sess', 'tool-calls.jsonl');
|
|
84
|
+
const stat = await fs.stat(filePath);
|
|
85
|
+
expect(stat.size).toBeGreaterThan(0);
|
|
86
|
+
});
|
|
87
|
+
it('dispatches to a registered skill-specific handler', async () => {
|
|
88
|
+
const calls = [];
|
|
89
|
+
const handler = {
|
|
90
|
+
skill: 'abap-cca',
|
|
91
|
+
apply: async (input) => { calls.push(input); },
|
|
92
|
+
};
|
|
93
|
+
registerCheckpointHandler(handler);
|
|
94
|
+
await applyToolResultCheckpoint(makeInput({ skill: 'abap-cca' }));
|
|
95
|
+
expect(calls).toHaveLength(1);
|
|
96
|
+
expect(calls[0]?.toolName).toBe('sap_sql_query');
|
|
97
|
+
});
|
|
98
|
+
it('does NOT dispatch to handlers for other skills', async () => {
|
|
99
|
+
const calls = [];
|
|
100
|
+
registerCheckpointHandler({
|
|
101
|
+
skill: 'abap-cca',
|
|
102
|
+
apply: async (input) => { calls.push(input); },
|
|
103
|
+
});
|
|
104
|
+
await applyToolResultCheckpoint(makeInput({ skill: 'abap-other' }));
|
|
105
|
+
expect(calls).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
it('swallows handler errors so a checkpoint failure never breaks a turn', async () => {
|
|
108
|
+
registerCheckpointHandler({
|
|
109
|
+
skill: 'abap-cca',
|
|
110
|
+
apply: async () => { throw new Error('handler exploded'); },
|
|
111
|
+
});
|
|
112
|
+
// Should not throw out — best-effort contract.
|
|
113
|
+
await expect(applyToolResultCheckpoint(makeInput())).resolves.toBeUndefined();
|
|
114
|
+
// Default JSONL still landed.
|
|
115
|
+
const filePath = path.join(checkpointsRoot(), 'test-sess', 'tool-calls.jsonl');
|
|
116
|
+
const stat = await fs.stat(filePath);
|
|
117
|
+
expect(stat.size).toBeGreaterThan(0);
|
|
118
|
+
});
|
|
119
|
+
it('handles unstringifiable args without crashing', async () => {
|
|
120
|
+
const cyclic = {};
|
|
121
|
+
cyclic.self = cyclic;
|
|
122
|
+
await applyToolResultCheckpoint(makeInput({ args: cyclic }));
|
|
123
|
+
const filePath = path.join(checkpointsRoot(), 'test-sess', 'tool-calls.jsonl');
|
|
124
|
+
const row = JSON.parse((await fs.readFile(filePath, 'utf-8')).trim());
|
|
125
|
+
expect(row.args).toBe('<unstringifiable>');
|
|
126
|
+
});
|
|
127
|
+
it('REGRESSION: a 30-tool-call turn produces a recoverable JSONL log', async () => {
|
|
128
|
+
// Mirror the /abap-cca DISCOVER+INVENTORY+ANALYZE pattern — many SQL
|
|
129
|
+
// probes followed by a few ATC + source-read calls. JSONL must contain
|
|
130
|
+
// every call with ordering preserved.
|
|
131
|
+
const calls = [
|
|
132
|
+
...Array.from({ length: 25 }, (_, i) => ({
|
|
133
|
+
toolName: 'sap_sql_query',
|
|
134
|
+
args: { query: `SELECT * FROM TADIR WHERE DEVCLASS = 'ZPKG${i}'` },
|
|
135
|
+
})),
|
|
136
|
+
{ toolName: 'sap_atc_run', args: { name: 'ZPAYMENTS', type: 'PROG' } },
|
|
137
|
+
{ toolName: 'sap_get_source', args: { name: 'ZPAYMENTS', type: 'PROG' } },
|
|
138
|
+
{ toolName: 'sap_get_source', args: { name: 'ZPAYMENTS_OLD', type: 'PROG' } },
|
|
139
|
+
{ toolName: 'sap_usage_references', args: { name: 'ZPAYMENTS', type: 'PROG' } },
|
|
140
|
+
{ toolName: 'sap_api_state', args: { objects: 'TABL:BSIK' } },
|
|
141
|
+
];
|
|
142
|
+
for (const c of calls) {
|
|
143
|
+
await applyToolResultCheckpoint(makeInput(c));
|
|
144
|
+
}
|
|
145
|
+
const filePath = path.join(checkpointsRoot(), 'test-sess', 'tool-calls.jsonl');
|
|
146
|
+
const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n');
|
|
147
|
+
expect(lines).toHaveLength(30);
|
|
148
|
+
// Verify ordering preserved.
|
|
149
|
+
expect(JSON.parse(lines[0]).tool).toBe('sap_sql_query');
|
|
150
|
+
expect(JSON.parse(lines[24]).tool).toBe('sap_sql_query');
|
|
151
|
+
expect(JSON.parse(lines[25]).tool).toBe('sap_atc_run');
|
|
152
|
+
expect(JSON.parse(lines[29]).tool).toBe('sap_api_state');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { collectTurnAssistantText } from '../turn-assistant-text.js';
|
|
3
|
+
describe('collectTurnAssistantText', () => {
|
|
4
|
+
it('returns the FULL turn-worth of assistant text when manifest lives in a non-final message', () => {
|
|
5
|
+
// The bug this fixes: /abap-cca emits the manifest in an earlier
|
|
6
|
+
// assistant message, then calls ask_question, then emits a wrap-up
|
|
7
|
+
// message. The pre-fix call site read only the final message and
|
|
8
|
+
// missed the manifest entirely. This test pins the regression.
|
|
9
|
+
const messages = [
|
|
10
|
+
{ role: 'user', content: 'go' },
|
|
11
|
+
// First assistant message: contains the manifest
|
|
12
|
+
{
|
|
13
|
+
role: 'assistant',
|
|
14
|
+
content: [
|
|
15
|
+
{ type: 'text', text: 'Long report ...\n<!-- csforge:cca-manifest\nartefact: cca-assessment\n-->' },
|
|
16
|
+
{ type: 'tool_use', name: 'ask_question', input: {} },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
// tool_result lands as a user message
|
|
20
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'done' }] },
|
|
21
|
+
// Second assistant message: closing prose, NO manifest
|
|
22
|
+
{
|
|
23
|
+
role: 'assistant',
|
|
24
|
+
content: [{ type: 'text', text: 'Done. Closing out.' }],
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
const out = collectTurnAssistantText(messages, 1);
|
|
28
|
+
expect(out).toContain('csforge:cca-manifest');
|
|
29
|
+
expect(out).toContain('Done. Closing out.');
|
|
30
|
+
});
|
|
31
|
+
it('joins multiple assistant messages with \\n\\n separators', () => {
|
|
32
|
+
const messages = [
|
|
33
|
+
{ role: 'user', content: 'go' },
|
|
34
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'first' }] },
|
|
35
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 't', content: 'x' }] },
|
|
36
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'second' }] },
|
|
37
|
+
];
|
|
38
|
+
expect(collectTurnAssistantText(messages, 1)).toBe('first\n\nsecond');
|
|
39
|
+
});
|
|
40
|
+
it('skips non-text content blocks (tool_use, tool_result wrapped, etc.)', () => {
|
|
41
|
+
const messages = [
|
|
42
|
+
{
|
|
43
|
+
role: 'assistant',
|
|
44
|
+
content: [
|
|
45
|
+
{ type: 'tool_use', name: 'sap_sql_query', input: { sql: 'SELECT' } },
|
|
46
|
+
{ type: 'text', text: 'visible' },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
expect(collectTurnAssistantText(messages, 0)).toBe('visible');
|
|
51
|
+
});
|
|
52
|
+
it('skips user / system / tool messages entirely', () => {
|
|
53
|
+
const messages = [
|
|
54
|
+
{ role: 'user', content: 'should not appear' },
|
|
55
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'asst-text' }] },
|
|
56
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 't', content: 'result' }] },
|
|
57
|
+
];
|
|
58
|
+
expect(collectTurnAssistantText(messages, 0)).toBe('asst-text');
|
|
59
|
+
});
|
|
60
|
+
it('returns empty string when no messages exist past turnStartCount', () => {
|
|
61
|
+
expect(collectTurnAssistantText([], 0)).toBe('');
|
|
62
|
+
expect(collectTurnAssistantText([{ role: 'user', content: 'x' }], 5)).toBe('');
|
|
63
|
+
expect(collectTurnAssistantText([{ role: 'user', content: 'x' }], 1)).toBe('');
|
|
64
|
+
});
|
|
65
|
+
it('treats non-string content arrays gracefully (string content, malformed blocks)', () => {
|
|
66
|
+
const messages = [
|
|
67
|
+
{ role: 'assistant', content: 'this is a string, not an array' },
|
|
68
|
+
{ role: 'assistant', content: [{ type: 'text', text: null }] },
|
|
69
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'valid' }] },
|
|
70
|
+
];
|
|
71
|
+
expect(collectTurnAssistantText(messages, 0)).toBe('valid');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { describeError, renderTurnError, logsRoot } from '../turn-error-ux.js';
|
|
6
|
+
const ORIGINAL_HOME = os.homedir();
|
|
7
|
+
describe('describeError', () => {
|
|
8
|
+
it('translates "terminated" to a human phrase', () => {
|
|
9
|
+
expect(describeError(new Error('terminated'))).toBe('provider stream closed mid-response');
|
|
10
|
+
});
|
|
11
|
+
it('translates ECONNRESET', () => {
|
|
12
|
+
expect(describeError(new Error('ECONNRESET'))).toBe('network connection reset');
|
|
13
|
+
});
|
|
14
|
+
it('translates ETIMEDOUT', () => {
|
|
15
|
+
expect(describeError(new Error('ETIMEDOUT'))).toBe('network timeout');
|
|
16
|
+
});
|
|
17
|
+
it('passes through unknown messages truncated to 200 chars', () => {
|
|
18
|
+
const long = 'x'.repeat(500);
|
|
19
|
+
expect(describeError(new Error(long))).toBe('x'.repeat(200));
|
|
20
|
+
});
|
|
21
|
+
it('handles non-Error throws (string, number)', () => {
|
|
22
|
+
expect(describeError('plain string')).toBe('plain string');
|
|
23
|
+
expect(describeError(42)).toBe('42');
|
|
24
|
+
});
|
|
25
|
+
it('handles null and undefined', () => {
|
|
26
|
+
expect(describeError(null)).toBe('unknown error');
|
|
27
|
+
expect(describeError(undefined)).toBe('unknown error');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('renderTurnError', () => {
|
|
31
|
+
let tempHome;
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'turn-error-ux-'));
|
|
34
|
+
process.env.HOME = tempHome;
|
|
35
|
+
process.env.USERPROFILE = tempHome;
|
|
36
|
+
});
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
process.env.HOME = ORIGINAL_HOME;
|
|
39
|
+
process.env.USERPROFILE = ORIGINAL_HOME;
|
|
40
|
+
await fs.rm(tempHome, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
it('prints a clean 4-6 line block with session id and resume hint', async () => {
|
|
43
|
+
const lines = [];
|
|
44
|
+
await renderTurnError({
|
|
45
|
+
err: new Error('terminated'),
|
|
46
|
+
session: { id: '17c04ce8' },
|
|
47
|
+
print: (l) => lines.push(l),
|
|
48
|
+
});
|
|
49
|
+
const text = lines.join('\n');
|
|
50
|
+
// Strip ANSI for content checks (chalk colours).
|
|
51
|
+
// eslint-disable-next-line no-control-regex
|
|
52
|
+
const plain = text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
53
|
+
expect(plain).toContain('Turn interrupted: provider stream closed mid-response');
|
|
54
|
+
expect(plain).toContain('Session: 17c04ce8');
|
|
55
|
+
expect(plain).toContain('Resume: cspeach --resume 17c04ce8');
|
|
56
|
+
// 6 content lines + 2 blank → 8 total.
|
|
57
|
+
expect(lines.length).toBeGreaterThanOrEqual(5);
|
|
58
|
+
expect(lines.length).toBeLessThanOrEqual(8);
|
|
59
|
+
});
|
|
60
|
+
it('writes a diagnostic log file under ~/.cspeach/logs/', async () => {
|
|
61
|
+
const err = new Error('terminated');
|
|
62
|
+
await renderTurnError({
|
|
63
|
+
err,
|
|
64
|
+
session: { id: 'abc12345' },
|
|
65
|
+
print: () => { },
|
|
66
|
+
});
|
|
67
|
+
const dir = logsRoot();
|
|
68
|
+
const files = await fs.readdir(dir);
|
|
69
|
+
const match = files.find((f) => f.startsWith('abc12345-') && f.endsWith('.log'));
|
|
70
|
+
expect(match).toBeDefined();
|
|
71
|
+
const content = await fs.readFile(path.join(dir, match), 'utf-8');
|
|
72
|
+
expect(content).toContain('# CSPeach turn error log');
|
|
73
|
+
expect(content).toContain('session: abc12345');
|
|
74
|
+
expect(content).toContain('Error: terminated');
|
|
75
|
+
expect(content).toContain('## stack');
|
|
76
|
+
});
|
|
77
|
+
it('includes the stream path when provided', async () => {
|
|
78
|
+
const lines = [];
|
|
79
|
+
await renderTurnError({
|
|
80
|
+
err: new Error('terminated'),
|
|
81
|
+
session: { id: 'abc' },
|
|
82
|
+
streamFilePath: '/path/to/stream.md',
|
|
83
|
+
print: (l) => lines.push(l),
|
|
84
|
+
});
|
|
85
|
+
// eslint-disable-next-line no-control-regex
|
|
86
|
+
const plain = lines.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
|
|
87
|
+
expect(plain).toContain('Stream: /path/to/stream.md');
|
|
88
|
+
});
|
|
89
|
+
it('handles non-Error throws gracefully', async () => {
|
|
90
|
+
const lines = [];
|
|
91
|
+
await renderTurnError({
|
|
92
|
+
err: 'something exploded',
|
|
93
|
+
session: { id: 'abc' },
|
|
94
|
+
print: (l) => lines.push(l),
|
|
95
|
+
});
|
|
96
|
+
// eslint-disable-next-line no-control-regex
|
|
97
|
+
const plain = lines.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
|
|
98
|
+
expect(plain).toContain('Turn interrupted: something exploded');
|
|
99
|
+
});
|
|
100
|
+
it('REGRESSION: replaces the bare [error] terminated UX with actionable info', async () => {
|
|
101
|
+
// The original v0.5 UX was a single line: "[error] terminated".
|
|
102
|
+
// After fix, the user sees: cause, session id, log path, resume hint.
|
|
103
|
+
const lines = [];
|
|
104
|
+
await renderTurnError({
|
|
105
|
+
err: new Error('terminated'),
|
|
106
|
+
session: { id: '17c04ce8' },
|
|
107
|
+
print: (l) => lines.push(l),
|
|
108
|
+
});
|
|
109
|
+
// eslint-disable-next-line no-control-regex
|
|
110
|
+
const plain = lines.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
|
|
111
|
+
expect(plain).not.toBe('[error] terminated');
|
|
112
|
+
expect(plain).toContain('Resume:');
|
|
113
|
+
expect(plain).toContain('Log:');
|
|
114
|
+
expect(plain).toContain('17c04ce8');
|
|
115
|
+
});
|
|
116
|
+
it('userInterrupted=true frames as cancellation, not crash', async () => {
|
|
117
|
+
const lines = [];
|
|
118
|
+
await renderTurnError({
|
|
119
|
+
err: new Error('aborted'),
|
|
120
|
+
session: { id: 'abc' },
|
|
121
|
+
userInterrupted: true,
|
|
122
|
+
print: (l) => lines.push(l),
|
|
123
|
+
});
|
|
124
|
+
// eslint-disable-next-line no-control-regex
|
|
125
|
+
const plain = lines.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
|
|
126
|
+
expect(plain).toContain('Turn cancelled.');
|
|
127
|
+
expect(plain).not.toContain('Turn interrupted');
|
|
128
|
+
expect(plain).toContain('Resume:');
|
|
129
|
+
// No log file written for user cancellations — they're not bugs.
|
|
130
|
+
expect(plain).not.toContain('Log:');
|
|
131
|
+
});
|
|
132
|
+
it('userInterrupted=true skips writing a diagnostic log file', async () => {
|
|
133
|
+
const before = await fs.readdir(logsRoot()).catch(() => []);
|
|
134
|
+
await renderTurnError({
|
|
135
|
+
err: new Error('aborted'),
|
|
136
|
+
session: { id: 'cancel-test' },
|
|
137
|
+
userInterrupted: true,
|
|
138
|
+
print: () => { },
|
|
139
|
+
});
|
|
140
|
+
const after = await fs.readdir(logsRoot()).catch(() => []);
|
|
141
|
+
// No new log files were created.
|
|
142
|
+
const newFiles = after.filter((f) => !before.includes(f) && f.startsWith('cancel-test-'));
|
|
143
|
+
expect(newFiles).toHaveLength(0);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { TurnStreamWriter } from '../turn-stream.js';
|
|
6
|
+
// Redirect HOME so streamsRoot() lands inside a temp dir for the test run.
|
|
7
|
+
// Restored in afterEach.
|
|
8
|
+
const ORIGINAL_HOME = os.homedir();
|
|
9
|
+
describe('TurnStreamWriter', () => {
|
|
10
|
+
let tempHome;
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'turn-stream-'));
|
|
13
|
+
process.env.HOME = tempHome;
|
|
14
|
+
process.env.USERPROFILE = tempHome;
|
|
15
|
+
});
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
process.env.HOME = ORIGINAL_HOME;
|
|
18
|
+
process.env.USERPROFILE = ORIGINAL_HOME;
|
|
19
|
+
// maxRetries handles Windows fs.rm ENOTEMPTY when a transient handle
|
|
20
|
+
// is still held on freshly-written stream files under parallel-suite load.
|
|
21
|
+
await fs.rm(tempHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
22
|
+
});
|
|
23
|
+
it('writes nothing when never appended (no empty file pollution)', async () => {
|
|
24
|
+
const w = new TurnStreamWriter('test-session-1');
|
|
25
|
+
await w.close();
|
|
26
|
+
await expect(fs.access(w.path())).rejects.toThrow();
|
|
27
|
+
});
|
|
28
|
+
it('appends text and creates the file lazily', async () => {
|
|
29
|
+
const w = new TurnStreamWriter('test-session-2');
|
|
30
|
+
await w.append('Hello, ');
|
|
31
|
+
await w.append('world.');
|
|
32
|
+
const content = await fs.readFile(w.path(), 'utf-8');
|
|
33
|
+
expect(content).toContain('# CSPeach turn stream');
|
|
34
|
+
expect(content).toContain('Hello, world.');
|
|
35
|
+
});
|
|
36
|
+
it('writes the file under ~/.cspeach/streams/<sessionId>/<ts>.md', async () => {
|
|
37
|
+
const startedAt = new Date('2026-05-09T20:09:56.746Z');
|
|
38
|
+
const w = new TurnStreamWriter('17c04ce8', startedAt);
|
|
39
|
+
await w.append('any text');
|
|
40
|
+
const expectedPrefix = path.join(tempHome, '.cspeach', 'streams', '17c04ce8');
|
|
41
|
+
expect(w.path().startsWith(expectedPrefix)).toBe(true);
|
|
42
|
+
expect(w.path()).toContain('2026-05-09T20-09-56-746Z');
|
|
43
|
+
});
|
|
44
|
+
it('close adds a default footer', async () => {
|
|
45
|
+
const w = new TurnStreamWriter('test-session-3');
|
|
46
|
+
await w.append('body content');
|
|
47
|
+
await w.close();
|
|
48
|
+
const content = await fs.readFile(w.path(), 'utf-8');
|
|
49
|
+
expect(content).toContain('body content');
|
|
50
|
+
expect(content).toContain('---');
|
|
51
|
+
expect(content).toContain('_closed ');
|
|
52
|
+
});
|
|
53
|
+
it('close with interrupted footer marks the failure reason', async () => {
|
|
54
|
+
const w = new TurnStreamWriter('test-session-4');
|
|
55
|
+
await w.append('partial output before crash');
|
|
56
|
+
await w.close('_interrupted: terminated_');
|
|
57
|
+
const content = await fs.readFile(w.path(), 'utf-8');
|
|
58
|
+
expect(content).toContain('partial output before crash');
|
|
59
|
+
expect(content).toContain('_interrupted: terminated_');
|
|
60
|
+
});
|
|
61
|
+
it('skips empty appends', async () => {
|
|
62
|
+
const w = new TurnStreamWriter('test-session-5');
|
|
63
|
+
await w.append('');
|
|
64
|
+
await w.append(undefined);
|
|
65
|
+
await w.close();
|
|
66
|
+
// Nothing was ever written → file does not exist.
|
|
67
|
+
await expect(fs.access(w.path())).rejects.toThrow();
|
|
68
|
+
});
|
|
69
|
+
it('close is idempotent for unused writers', async () => {
|
|
70
|
+
const w = new TurnStreamWriter('test-session-6');
|
|
71
|
+
await w.close();
|
|
72
|
+
await w.close();
|
|
73
|
+
await expect(fs.access(w.path())).rejects.toThrow();
|
|
74
|
+
});
|
|
75
|
+
it('REGRESSION: simulates a 200KB-ish report stream — content survives intact', async () => {
|
|
76
|
+
// Real-world worst case: model emits a large consulting report and the
|
|
77
|
+
// stream cuts mid-flight. Even at 200KB the writer must produce a complete
|
|
78
|
+
// file with all the streamed text.
|
|
79
|
+
// Bumped timeout: on Windows under parallel-test-file load, 1000
|
|
80
|
+
// sequential appends + tempdir cleanup can exceed the default 5s.
|
|
81
|
+
// Same Phase-1.5 pattern as index-files truncate test — large-N
|
|
82
|
+
// filesystem tests on Windows are timeout-sensitive under load.
|
|
83
|
+
const w = new TurnStreamWriter('test-session-7');
|
|
84
|
+
// Emit 1000 chunks of ~200 bytes each = ~200 KB
|
|
85
|
+
for (let i = 0; i < 1000; i++) {
|
|
86
|
+
await w.append(`chunk ${i.toString().padStart(4, '0')}: ${'lorem ipsum '.repeat(15)}\n`);
|
|
87
|
+
}
|
|
88
|
+
await w.close('_interrupted: terminated_');
|
|
89
|
+
const content = await fs.readFile(w.path(), 'utf-8');
|
|
90
|
+
// Spot-check that all chunks landed and the file ends with the
|
|
91
|
+
// interruption footer.
|
|
92
|
+
expect(content).toContain('chunk 0000:');
|
|
93
|
+
expect(content).toContain('chunk 0500:');
|
|
94
|
+
expect(content).toContain('chunk 0999:');
|
|
95
|
+
expect(content).toContain('_interrupted: terminated_');
|
|
96
|
+
// Sanity on size — should be in the right ballpark.
|
|
97
|
+
const stats = await fs.stat(w.path());
|
|
98
|
+
expect(stats.size).toBeGreaterThan(150_000);
|
|
99
|
+
}, 60_000);
|
|
100
|
+
});
|