@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.
Files changed (107) hide show
  1. package/dist/agent/__tests__/crash-recover.integration.test.js +254 -0
  2. package/dist/agent/__tests__/loop-save-hook.test.js +188 -0
  3. package/dist/agent/__tests__/maybe-build-project-context.test.js +247 -0
  4. package/dist/agent/__tests__/project-context-injection.test.js +161 -0
  5. package/dist/agent/__tests__/repair-partial.test.js +79 -0
  6. package/dist/agent/__tests__/retry-key.test.js +77 -0
  7. package/dist/agent/__tests__/sap-connection-adapter.test.js +132 -0
  8. package/dist/agent/__tests__/skill-checkpoint.test.js +154 -0
  9. package/dist/agent/__tests__/turn-assistant-text.test.js +73 -0
  10. package/dist/agent/__tests__/turn-error-ux.test.js +145 -0
  11. package/dist/agent/__tests__/turn-stream.test.js +100 -0
  12. package/dist/agent/__tests__/turn-watchdog.test.js +126 -0
  13. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +30 -0
  14. package/dist/agent/providers/__tests__/byok-provider.test.js +32 -0
  15. package/dist/agent/providers/__tests__/factory.test.js +33 -0
  16. package/dist/agent/providers/__tests__/local-provider.test.js +55 -0
  17. package/dist/approvals/__tests__/advisory-prompt.test.js +57 -0
  18. package/dist/approvals/__tests__/advisory-render.test.js +54 -0
  19. package/dist/auth/__tests__/auth-file.test.js +42 -0
  20. package/dist/auth/__tests__/me.test.js +43 -0
  21. package/dist/classifier/__tests__/client.test.js +70 -0
  22. package/dist/commands/__tests__/config-set-write-mode.test.js +14 -0
  23. package/dist/commands/__tests__/login.test.js +152 -0
  24. package/dist/commands/__tests__/logout.test.js +39 -0
  25. package/dist/commands/__tests__/project-context-impact.test.js +173 -0
  26. package/dist/commands/__tests__/spec-gap-status.test.js +31 -0
  27. package/dist/commands/__tests__/whoami.test.js +91 -0
  28. package/dist/config/__tests__/llm-config.test.js +28 -0
  29. package/dist/config/__tests__/shell-exec-config.test.js +76 -0
  30. package/dist/config/__tests__/write-mode.test.js +36 -0
  31. package/dist/doctor/__tests__/check-forge-rules.test.js +134 -0
  32. package/dist/doctor/__tests__/check-llm-mode.test.js +21 -0
  33. package/dist/doctor/__tests__/check-write-mode.test.js +34 -0
  34. package/dist/index.js +0 -0
  35. package/dist/project-context/__tests__/conventions.test.js +221 -0
  36. package/dist/project-context/__tests__/detect.test.js +177 -0
  37. package/dist/project-context/__tests__/domain-abap-cloud.test.js +119 -0
  38. package/dist/project-context/__tests__/domain-abapgit.test.js +161 -0
  39. package/dist/project-context/__tests__/domain-cap.test.js +168 -0
  40. package/dist/project-context/__tests__/domain-fiori.test.js +282 -0
  41. package/dist/project-context/__tests__/git.test.js +131 -0
  42. package/dist/project-context/__tests__/index-files.test.js +195 -0
  43. package/dist/project-context/__tests__/index.test.js +133 -0
  44. package/dist/project-context/__tests__/render.test.js +322 -0
  45. package/dist/project-context/__tests__/types.test.js +73 -0
  46. package/dist/projects/__tests__/build.test.js +52 -0
  47. package/dist/projects/__tests__/canonicalize.test.js +47 -0
  48. package/dist/projects/__tests__/email-template.test.js +71 -0
  49. package/dist/projects/__tests__/expand-text-attachments.test.js +105 -0
  50. package/dist/projects/__tests__/extract-cca.test.js +141 -0
  51. package/dist/projects/__tests__/extract-design.test.js +62 -0
  52. package/dist/projects/__tests__/extract-estimate.test.js +58 -0
  53. package/dist/projects/__tests__/extract-modernize.test.js +127 -0
  54. package/dist/projects/__tests__/extract-spec-gap.test.js +122 -0
  55. package/dist/projects/__tests__/extract-test-coverage.test.js +133 -0
  56. package/dist/projects/__tests__/filename.test.js +32 -0
  57. package/dist/projects/__tests__/promote-command.test.js +170 -0
  58. package/dist/projects/__tests__/promote.test.js +181 -0
  59. package/dist/projects/__tests__/save-command.test.js +216 -0
  60. package/dist/projects/__tests__/save.test.js +41 -0
  61. package/dist/projects/__tests__/status.test.js +208 -0
  62. package/dist/projects/__tests__/types.test.js +41 -0
  63. package/dist/projects/__tests__/validate.test.js +165 -0
  64. package/dist/projects/__tests__/workspace.test.js +337 -0
  65. package/dist/repl/__tests__/current-transport.test.js +43 -0
  66. package/dist/repl/__tests__/diff-display.test.js +59 -0
  67. package/dist/repl/__tests__/file-picker.test.js +97 -0
  68. package/dist/repl/__tests__/rule8-detector.test.js +114 -0
  69. package/dist/repl/__tests__/safety-confirm.test.js +130 -0
  70. package/dist/repl/__tests__/safety-mode-state.test.js +42 -0
  71. package/dist/sap/__tests__/system-info.test.js +344 -0
  72. package/dist/session/__tests__/store-discovery.test.js +114 -0
  73. package/dist/session/__tests__/time-ago.test.js +53 -0
  74. package/dist/skills/__tests__/canonical.test.js +20 -0
  75. package/dist/skills/__tests__/manifest-client.test.js +88 -0
  76. package/dist/skills/__tests__/promotion-dispatch.test.js +28 -0
  77. package/dist/skills/__tests__/source-managed.test.js +60 -0
  78. package/dist/tools/__tests__/_background-shared.test.js +69 -0
  79. package/dist/tools/__tests__/_command-shared.test.js +59 -0
  80. package/dist/tools/__tests__/_filesystem-shared.test.js +42 -0
  81. package/dist/tools/__tests__/_flag.test.js +66 -0
  82. package/dist/tools/__tests__/_helpers.js +17 -0
  83. package/dist/tools/__tests__/_web-shared.test.js +143 -0
  84. package/dist/tools/__tests__/agent_run.test.js +166 -0
  85. package/dist/tools/__tests__/approval-advisory.test.js +66 -0
  86. package/dist/tools/__tests__/background_run.test.js +113 -0
  87. package/dist/tools/__tests__/convention_get.test.js +53 -0
  88. package/dist/tools/__tests__/file-edit.test.js +72 -0
  89. package/dist/tools/__tests__/file-read.test.js +81 -0
  90. package/dist/tools/__tests__/file-write.test.js +68 -0
  91. package/dist/tools/__tests__/glob.test.js +128 -0
  92. package/dist/tools/__tests__/grep.test.js +71 -0
  93. package/dist/tools/__tests__/monitor_emit.test.js +96 -0
  94. package/dist/tools/__tests__/playbook_get.test.js +109 -0
  95. package/dist/tools/__tests__/project_context_get.test.js +71 -0
  96. package/dist/tools/__tests__/registry-category.test.js +60 -0
  97. package/dist/tools/__tests__/sap-read-extras.test.js +196 -0
  98. package/dist/tools/__tests__/sap-write-extras.test.js +573 -0
  99. package/dist/tools/__tests__/schedule_create.test.js +85 -0
  100. package/dist/tools/__tests__/shell_exec.test.js +135 -0
  101. package/dist/tools/__tests__/transport-safety.test.js +106 -0
  102. package/dist/tools/__tests__/update-method-intercept.test.js +128 -0
  103. package/dist/tools/__tests__/web_fetch.test.js +329 -0
  104. package/dist/tools/__tests__/web_search.test.js +215 -0
  105. package/dist/tools/__tests__/write-mode.test.js +32 -0
  106. package/dist/ui/__tests__/login-banner.test.js +69 -0
  107. package/package.json +82 -83
@@ -0,0 +1,247 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } 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 { PROJECT_CONTEXT_ENV_FLAG, __resetProjectContextCacheForTests, isFlagOn, maybeBuildProjectContext, resetProjectContextCache, } from '../maybe-build-project-context.js';
6
+ let root;
7
+ const originalEnv = process.env[PROJECT_CONTEXT_ENV_FLAG];
8
+ beforeEach(async () => {
9
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-pctx-loop-'));
10
+ __resetProjectContextCacheForTests();
11
+ delete process.env[PROJECT_CONTEXT_ENV_FLAG];
12
+ });
13
+ afterEach(async () => {
14
+ await fs.rm(root, { recursive: true, force: true });
15
+ if (originalEnv === undefined) {
16
+ delete process.env[PROJECT_CONTEXT_ENV_FLAG];
17
+ }
18
+ else {
19
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = originalEnv;
20
+ }
21
+ __resetProjectContextCacheForTests();
22
+ });
23
+ async function writeFile(rel, content = '') {
24
+ const full = path.join(root, rel);
25
+ await fs.mkdir(path.dirname(full), { recursive: true });
26
+ await fs.writeFile(full, content, 'utf-8');
27
+ }
28
+ describe('isFlagOn', () => {
29
+ it('returns false when the env var is unset', () => {
30
+ delete process.env[PROJECT_CONTEXT_ENV_FLAG];
31
+ expect(isFlagOn()).toBe(false);
32
+ });
33
+ it('returns true only for the exact value "on"', () => {
34
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
35
+ expect(isFlagOn()).toBe(true);
36
+ });
37
+ it('returns false for "ON", "true", "1", or any other value', () => {
38
+ for (const v of ['ON', 'On', 'true', '1', 'yes', 'enabled', '']) {
39
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = v;
40
+ expect(isFlagOn()).toBe(false);
41
+ }
42
+ });
43
+ });
44
+ describe('maybeBuildProjectContext — flag off (default)', () => {
45
+ it('returns empty string and does no work when env var is unset', async () => {
46
+ await writeFile('.abapgit.xml', '<xml/>');
47
+ await writeFile('src/zhello.prog.abap', '');
48
+ const block = await maybeBuildProjectContext({ cwd: root });
49
+ expect(block).toBe('');
50
+ });
51
+ it('returns empty string when env var is set to a non-on value', async () => {
52
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'true';
53
+ await writeFile('.abapgit.xml', '<xml/>');
54
+ const block = await maybeBuildProjectContext({ cwd: root });
55
+ expect(block).toBe('');
56
+ });
57
+ });
58
+ describe('maybeBuildProjectContext — flag on', () => {
59
+ beforeEach(() => {
60
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
61
+ });
62
+ it('builds a project_context block for a recognized abapgit project', async () => {
63
+ await writeFile('.abapgit.xml', '<xml/>');
64
+ await writeFile('src/zhello.prog.abap', '');
65
+ const block = await maybeBuildProjectContext({ cwd: root });
66
+ expect(block).toContain('<project_context type="abapgit">');
67
+ expect(block).toContain('ZHELLO');
68
+ expect(block).toContain('</project_context>');
69
+ });
70
+ it('builds a project_context block for an unknown directory (since 1F)', async () => {
71
+ // Phase 1F changed the contract: getProjectContext returns a real
72
+ // ProjectContext for unknown directories rather than null. The
73
+ // helper should surface the rendered block in that case too —
74
+ // the model still gets useful info (file index, git state) even
75
+ // without a recognized project shape.
76
+ await writeFile('random.txt', 'just a file');
77
+ const block = await maybeBuildProjectContext({ cwd: root });
78
+ expect(block).toContain('<project_context type="unknown">');
79
+ });
80
+ it('returns empty string when getProjectContext returns null (cwd unreadable)', async () => {
81
+ const block = await maybeBuildProjectContext({
82
+ cwd: path.join(root, 'this-does-not-exist'),
83
+ });
84
+ expect(block).toBe('');
85
+ });
86
+ });
87
+ describe('maybeBuildProjectContext — caching', () => {
88
+ beforeEach(() => {
89
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
90
+ });
91
+ it('caches the rendered block at module scope (one walk per process)', async () => {
92
+ await writeFile('.abapgit.xml', '<xml/>');
93
+ await writeFile('src/zalpha.prog.abap', '');
94
+ const first = await maybeBuildProjectContext({ cwd: root });
95
+ expect(first).toContain('ZALPHA');
96
+ // Mutate the project AFTER the first call — the cache should hide
97
+ // the change. (This is the documented Phase 2 behavior; the
98
+ // refresh-on-demand tool is Phase 3.)
99
+ await writeFile('src/zbeta.prog.abap', '');
100
+ const second = await maybeBuildProjectContext({ cwd: root });
101
+ expect(second).toBe(first);
102
+ expect(second).not.toContain('ZBETA');
103
+ });
104
+ it('caches even the empty case so a missing-cwd does not retry', async () => {
105
+ const bogus = path.join(root, 'nope');
106
+ const first = await maybeBuildProjectContext({ cwd: bogus });
107
+ expect(first).toBe('');
108
+ // Create the dir AFTER the first call — cache should still be empty.
109
+ await fs.mkdir(bogus, { recursive: true });
110
+ await fs.writeFile(path.join(bogus, '.abapgit.xml'), '<xml/>', 'utf-8');
111
+ const second = await maybeBuildProjectContext({ cwd: bogus });
112
+ expect(second).toBe('');
113
+ });
114
+ it('test-only reset hook lets tests drive the helper from a clean state', async () => {
115
+ await writeFile('.abapgit.xml', '<xml/>');
116
+ const first = await maybeBuildProjectContext({ cwd: root });
117
+ expect(first).not.toBe('');
118
+ __resetProjectContextCacheForTests();
119
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'off';
120
+ const after = await maybeBuildProjectContext({ cwd: root });
121
+ expect(after).toBe('');
122
+ });
123
+ });
124
+ describe('maybeBuildProjectContext — fault tolerance', () => {
125
+ it('does not throw on null-byte cwd (hostile input)', async () => {
126
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
127
+ const block = await maybeBuildProjectContext({ cwd: '\0\0\0' });
128
+ expect(block).toBe('');
129
+ });
130
+ it('returns empty for empty-string cwd without walking the test runner CWD', async () => {
131
+ // Reviewer flagged this edge case: Node fs APIs resolve '' to
132
+ // process.cwd(), which would silently walk wherever the test
133
+ // runner was launched from. The helper must reject empty cwd
134
+ // explicitly.
135
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
136
+ const block = await maybeBuildProjectContext({ cwd: '' });
137
+ expect(block).toBe('');
138
+ // And it should not have populated the cache — a subsequent call
139
+ // with a real cwd must still do real work.
140
+ await writeFile('.abapgit.xml', '<xml/>');
141
+ await writeFile('src/zhello.prog.abap', '');
142
+ const real = await maybeBuildProjectContext({ cwd: root });
143
+ expect(real).toContain('<project_context type="abapgit">');
144
+ });
145
+ });
146
+ describe('error path — never-throws contract violation', () => {
147
+ // We use vi.spyOn on process.stderr.write rather than mocking the
148
+ // project-context module (which would force every other test to
149
+ // re-mock). The trigger is a hostile input that the helper's own
150
+ // logic forwards to getProjectContext; getProjectContext itself
151
+ // returns null on hostile input rather than throwing, so we
152
+ // additionally spy + force a throw via vi.spyOn on the imported
153
+ // function. See test below.
154
+ it('emits a one-shot stderr warning if getProjectContext violates never-throws', async () => {
155
+ const projectContextModule = await import('../../project-context/index.js');
156
+ const stderrWrites = [];
157
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
158
+ stderrWrites.push(typeof chunk === 'string' ? chunk : chunk.toString());
159
+ return true;
160
+ });
161
+ const getCtxSpy = vi
162
+ .spyOn(projectContextModule, 'getProjectContext')
163
+ .mockImplementation(async () => {
164
+ throw new Error('synthetic regression — never-throws contract violated');
165
+ });
166
+ try {
167
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
168
+ // First call — should warn, return ''.
169
+ const first = await maybeBuildProjectContext({ cwd: root });
170
+ expect(first).toBe('');
171
+ const warnLines = stderrWrites.filter((s) => s.includes('[project-context] disabled for this session'));
172
+ expect(warnLines).toHaveLength(1);
173
+ expect(warnLines[0]).toContain('synthetic regression');
174
+ // Second call — must NOT re-warn (one-shot latch). Cached '' is
175
+ // returned without re-calling getProjectContext.
176
+ const second = await maybeBuildProjectContext({ cwd: root });
177
+ expect(second).toBe('');
178
+ const warnLinesAfter = stderrWrites.filter((s) => s.includes('[project-context] disabled for this session'));
179
+ expect(warnLinesAfter).toHaveLength(1); // still 1, not 2
180
+ }
181
+ finally {
182
+ stderrSpy.mockRestore();
183
+ getCtxSpy.mockRestore();
184
+ }
185
+ });
186
+ it('public reset clears the warned-once latch so a fresh attempt can warn anew', async () => {
187
+ const projectContextModule = await import('../../project-context/index.js');
188
+ const stderrWrites = [];
189
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
190
+ stderrWrites.push(typeof chunk === 'string' ? chunk : chunk.toString());
191
+ return true;
192
+ });
193
+ const getCtxSpy = vi
194
+ .spyOn(projectContextModule, 'getProjectContext')
195
+ .mockImplementation(async () => {
196
+ throw new Error('first failure');
197
+ });
198
+ try {
199
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
200
+ await maybeBuildProjectContext({ cwd: root });
201
+ expect(stderrWrites.filter((s) => s.includes('[project-context]')).length).toBe(1);
202
+ // Reset clears both the cache AND the warned-once latch (per the
203
+ // public API's contract).
204
+ resetProjectContextCache();
205
+ getCtxSpy.mockImplementation(async () => {
206
+ throw new Error('second failure');
207
+ });
208
+ await maybeBuildProjectContext({ cwd: root });
209
+ const allWarnings = stderrWrites.filter((s) => s.includes('[project-context]'));
210
+ expect(allWarnings).toHaveLength(2);
211
+ expect(allWarnings[1]).toContain('second failure');
212
+ }
213
+ finally {
214
+ stderrSpy.mockRestore();
215
+ getCtxSpy.mockRestore();
216
+ }
217
+ });
218
+ });
219
+ describe('resetProjectContextCache (public API for Phase 3 refresh)', () => {
220
+ beforeEach(() => {
221
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
222
+ });
223
+ it('forces the next call to recompute, surfacing fresh project state', async () => {
224
+ await writeFile('.abapgit.xml', '<xml/>');
225
+ await writeFile('src/zfirst.prog.abap', '');
226
+ const before = await maybeBuildProjectContext({ cwd: root });
227
+ expect(before).toContain('ZFIRST');
228
+ expect(before).not.toContain('ZSECOND');
229
+ // Add a new object AFTER the first call. Without reset, the cache
230
+ // would hide it.
231
+ await writeFile('src/zsecond.prog.abap', '');
232
+ resetProjectContextCache();
233
+ const after = await maybeBuildProjectContext({ cwd: root });
234
+ expect(after).toContain('ZFIRST');
235
+ expect(after).toContain('ZSECOND');
236
+ });
237
+ it('is what the test-only alias delegates to (single source of truth)', async () => {
238
+ await writeFile('.abapgit.xml', '<xml/>');
239
+ await writeFile('src/zhello.prog.abap', '');
240
+ await maybeBuildProjectContext({ cwd: root });
241
+ // Both should produce the same effect: the next call recomputes.
242
+ __resetProjectContextCacheForTests();
243
+ await writeFile('src/zafter.prog.abap', '');
244
+ const out = await maybeBuildProjectContext({ cwd: root });
245
+ expect(out).toContain('ZAFTER');
246
+ });
247
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Phase 2 of Track B — integration test for the project-context
3
+ * injection gate in runTurn.
4
+ *
5
+ * Covers two concerns:
6
+ *
7
+ * 1. `hasProjectContextBlock` (the dup-injection guard) — covers the
8
+ * shape of the messages array (user/assistant, string/multimodal
9
+ * content) the loop has to handle.
10
+ *
11
+ * 2. End-to-end behavior of the helper + guard composition: with
12
+ * flag off, never inject. With flag on, inject exactly once per
13
+ * session (subsequent turns short-circuit because the first turn's
14
+ * injection is now visible in session.messages).
15
+ *
16
+ * We do NOT spin up a real LLM stream here. The runTurn integration
17
+ * surface is too wide for that to be worth the mock scaffolding. The
18
+ * helper + guard are the two new things Phase 2 added; they're tested
19
+ * in isolation. The wiring between them is one short block in loop.ts
20
+ * that's reviewed visually.
21
+ */
22
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
23
+ import { promises as fs } from 'node:fs';
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ import { hasProjectContextBlock } from '../loop.js';
27
+ import { PROJECT_CONTEXT_ENV_FLAG, __resetProjectContextCacheForTests, maybeBuildProjectContext, } from '../maybe-build-project-context.js';
28
+ const originalEnv = process.env[PROJECT_CONTEXT_ENV_FLAG];
29
+ beforeEach(() => {
30
+ __resetProjectContextCacheForTests();
31
+ delete process.env[PROJECT_CONTEXT_ENV_FLAG];
32
+ });
33
+ afterEach(() => {
34
+ if (originalEnv === undefined) {
35
+ delete process.env[PROJECT_CONTEXT_ENV_FLAG];
36
+ }
37
+ else {
38
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = originalEnv;
39
+ }
40
+ __resetProjectContextCacheForTests();
41
+ });
42
+ describe('hasProjectContextBlock', () => {
43
+ it('returns false on an empty session', () => {
44
+ expect(hasProjectContextBlock([])).toBe(false);
45
+ });
46
+ it('returns false when only assistant messages contain the block (defensive)', () => {
47
+ // Edge case: an assistant tool-result containing the literal string
48
+ // shouldn't count as "user already saw it." Phase 2 only injects on
49
+ // user turns, so the gate must check role.
50
+ expect(hasProjectContextBlock([
51
+ { role: 'assistant', content: 'I see <project_context type="abapgit"> in your project.' },
52
+ ])).toBe(false);
53
+ });
54
+ it('returns false when no message contains the block', () => {
55
+ expect(hasProjectContextBlock([
56
+ { role: 'user', content: 'just a plain message' },
57
+ { role: 'assistant', content: 'response' },
58
+ { role: 'user', content: '<session_context><package>ZFI</package></session_context>\n\nfollow-up' },
59
+ ])).toBe(false);
60
+ });
61
+ it('returns true when any user message contains <project_context', () => {
62
+ expect(hasProjectContextBlock([
63
+ { role: 'user', content: '<project_context type="abapgit">\n <root>/x</root>\n</project_context>\n\nthe ask' },
64
+ ])).toBe(true);
65
+ });
66
+ it('returns true even when the project_context appears mid-turn (reroute case)', () => {
67
+ expect(hasProjectContextBlock([
68
+ { role: 'user', content: 'first turn — classifier injected this' },
69
+ { role: 'assistant', content: 'rerouted' },
70
+ { role: 'user', content: '<project_context type="cap-node">\n</project_context>\n\nfollow-up' },
71
+ ])).toBe(true);
72
+ });
73
+ it('safely handles non-string content (multimodal user message)', () => {
74
+ // The Anthropic messages API allows array-shaped content for
75
+ // multimodal turns. The current loop never produces those for the
76
+ // user side, but the guard must not throw if they appear.
77
+ expect(hasProjectContextBlock([
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ { role: 'user', content: [{ type: 'text', text: 'hello' }] },
80
+ ])).toBe(false);
81
+ });
82
+ });
83
+ /* ── Composition: helper + guard simulating runTurn's enrichment block ── */
84
+ describe('Phase 2 enrichment composition (matches runTurn wiring)', () => {
85
+ let root;
86
+ beforeEach(async () => {
87
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-pctx-int-'));
88
+ await fs.writeFile(path.join(root, '.abapgit.xml'), '<xml/>', 'utf-8');
89
+ await fs.mkdir(path.join(root, 'src'), { recursive: true });
90
+ await fs.writeFile(path.join(root, 'src', 'zhello.prog.abap'), '', 'utf-8');
91
+ });
92
+ afterEach(async () => {
93
+ await fs.rm(root, { recursive: true, force: true });
94
+ });
95
+ /**
96
+ * Mirrors the exact composition runTurn uses, so a regression in
97
+ * either helper OR the wiring shape is caught here. The test does
98
+ * not invoke runTurn — too much side-effect surface — but the logic
99
+ * being tested is the same three-line dance.
100
+ */
101
+ async function simulateEnrichment(messages, cwd, enrichedUserContent) {
102
+ let projectContextBlock = '';
103
+ if (!hasProjectContextBlock(messages)) {
104
+ projectContextBlock = await maybeBuildProjectContext({ cwd });
105
+ }
106
+ return projectContextBlock
107
+ ? `${projectContextBlock}\n\n${enrichedUserContent}`
108
+ : enrichedUserContent;
109
+ }
110
+ it('flag OFF: first turn — content is unchanged, no project_context prepended', async () => {
111
+ const messages = [];
112
+ const result = await simulateEnrichment(messages, root, 'create a new payment class');
113
+ expect(result).toBe('create a new payment class');
114
+ expect(result).not.toContain('<project_context');
115
+ });
116
+ it('flag ON: first turn — project_context prepended once', async () => {
117
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
118
+ const messages = [];
119
+ const result = await simulateEnrichment(messages, root, 'create a new payment class');
120
+ expect(result).toContain('<project_context type="abapgit">');
121
+ expect(result).toContain('ZHELLO');
122
+ expect(result).toContain('create a new payment class');
123
+ // Original user content is preserved verbatim after the block.
124
+ expect(result.endsWith('create a new payment class')).toBe(true);
125
+ });
126
+ it('flag ON: second turn — guard short-circuits, no duplicate injection', async () => {
127
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
128
+ const messages = [];
129
+ // Turn 1
130
+ const t1 = await simulateEnrichment(messages, root, 'first ask');
131
+ messages.push({ role: 'user', content: t1 });
132
+ messages.push({ role: 'assistant', content: 'first reply' });
133
+ // Turn 2
134
+ const t2 = await simulateEnrichment(messages, root, 'follow-up ask');
135
+ // Turn 2's content should NOT carry a second copy of project_context.
136
+ expect((t2.match(/<project_context/g) ?? []).length).toBe(0);
137
+ expect(t2).toBe('follow-up ask');
138
+ });
139
+ it('flag ON then OFF mid-session: still no inject on turn 2 because turn 1 has it', async () => {
140
+ // Realistic scenario: user starts session with flag on, exits the
141
+ // process or unsets the flag, comes back to the resumed session.
142
+ // The guard relies on session.messages, not the env var, so the
143
+ // resumed session keeps the block from turn 1 and doesn't try to
144
+ // re-add. (The cache is process-lifetime, so a fresh process with
145
+ // flag off won't even build the block.)
146
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
147
+ const messages = [];
148
+ const t1 = await simulateEnrichment(messages, root, 'first');
149
+ messages.push({ role: 'user', content: t1 });
150
+ delete process.env[PROJECT_CONTEXT_ENV_FLAG];
151
+ __resetProjectContextCacheForTests();
152
+ const t2 = await simulateEnrichment(messages, root, 'second');
153
+ expect(t2).toBe('second');
154
+ });
155
+ it('flag ON: detection-fails path — no injection, content unchanged', async () => {
156
+ process.env[PROJECT_CONTEXT_ENV_FLAG] = 'on';
157
+ const messages = [];
158
+ const result = await simulateEnrichment(messages, '\0\0\0', 'the ask');
159
+ expect(result).toBe('the ask');
160
+ });
161
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { repairPartialBlocks } from '../repair-partial.js';
3
+ describe('repairPartialBlocks', () => {
4
+ it('keeps complete tool_use blocks (input parsed)', () => {
5
+ const blocks = [
6
+ { type: 'tool_use', name: 'sap_sql_query', input: { query: 'SELECT 1' } },
7
+ ];
8
+ expect(repairPartialBlocks(blocks)).toEqual(blocks);
9
+ });
10
+ it('drops tool_use blocks whose partial_json never finished parsing', () => {
11
+ const blocks = [
12
+ { type: 'tool_use', name: 'sap_sql_query', partial_json: '{"query":"SELECT * F' },
13
+ ];
14
+ expect(repairPartialBlocks(blocks)).toEqual([]);
15
+ });
16
+ it('drops tool_use blocks with neither input nor partial_json', () => {
17
+ const blocks = [
18
+ { type: 'tool_use', name: 'sap_sql_query' },
19
+ ];
20
+ expect(repairPartialBlocks(blocks)).toEqual([]);
21
+ });
22
+ it('keeps text blocks even if truncated mid-sentence', () => {
23
+ const blocks = [
24
+ { type: 'text', text: 'Starting analysis. The package contains 14 obj' },
25
+ ];
26
+ expect(repairPartialBlocks(blocks)).toEqual(blocks);
27
+ });
28
+ it('keeps thinking blocks for next-turn context preservation', () => {
29
+ const blocks = [
30
+ { type: 'thinking', thinking: 'Let me check TADIR for...' },
31
+ ];
32
+ expect(repairPartialBlocks(blocks)).toEqual(blocks);
33
+ });
34
+ it('handles a realistic mixed-content interruption', () => {
35
+ const blocks = [
36
+ { type: 'thinking', thinking: 'Plan: probe TDEVC, then TADIR.' },
37
+ { type: 'text', text: 'Running the package validation now.' },
38
+ // Two tool calls — the first completed, the second was mid-stream when
39
+ // the connection dropped.
40
+ { type: 'tool_use', name: 'sap_sql_query', input: { query: 'SELECT * FROM TDEVC' } },
41
+ { type: 'tool_use', name: 'sap_sql_query', partial_json: '{"query":"SEL' },
42
+ ];
43
+ expect(repairPartialBlocks(blocks)).toEqual([
44
+ { type: 'thinking', thinking: 'Plan: probe TDEVC, then TADIR.' },
45
+ { type: 'text', text: 'Running the package validation now.' },
46
+ { type: 'tool_use', name: 'sap_sql_query', input: { query: 'SELECT * FROM TDEVC' } },
47
+ ]);
48
+ });
49
+ it('returns empty array unchanged', () => {
50
+ expect(repairPartialBlocks([])).toEqual([]);
51
+ });
52
+ it('skips null / non-object entries defensively', () => {
53
+ const blocks = [
54
+ null,
55
+ undefined,
56
+ 'not-a-block',
57
+ { type: 'text', text: 'real block' },
58
+ ];
59
+ expect(repairPartialBlocks(blocks)).toEqual([
60
+ { type: 'text', text: 'real block' },
61
+ ]);
62
+ });
63
+ it('REGRESSION: simulates the lost ZDPR_PAYMENTS turn — assistant message survives the crash', () => {
64
+ // Simulates the captured-from-disk state of session 17c04ce8: the model
65
+ // was streaming the final consulting report when Undici closed the HTTP
66
+ // stream. Accumulated content held a thinking block + the partial report
67
+ // text. No tool_use was in flight at that exact moment.
68
+ const blocks = [
69
+ { type: 'thinking', thinking: 'Synthesise findings for ZPAYMENTS / ZPAYMENTS_OLD.' },
70
+ { type: 'text', text: '## ABAP CCA — ZDPR_PAYMENTS\n\n## TL;DR\n\nHeadline: 13-object package...' },
71
+ ];
72
+ const repaired = repairPartialBlocks(blocks);
73
+ // Both blocks survive — the user's report text is recovered for resume.
74
+ expect(repaired).toHaveLength(2);
75
+ expect(repaired[0]?.type).toBe('thinking');
76
+ expect(repaired[1]?.type).toBe('text');
77
+ expect(repaired[1]?.text).toContain('ZDPR_PAYMENTS');
78
+ });
79
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { objectKeyFromInput } from '../retry-key.js';
3
+ describe('objectKeyFromInput', () => {
4
+ describe('object-identifying tools (preserves original semantics)', () => {
5
+ it('keys by tool:name:type when both present', () => {
6
+ const k = objectKeyFromInput('sap_set_source', { name: 'ZFOO', type: 'PROG' });
7
+ expect(k).toBe('sap_set_source:ZFOO:PROG');
8
+ });
9
+ it('keys by tool:name: when only name present', () => {
10
+ const k = objectKeyFromInput('sap_activate', { name: 'ZBAR' });
11
+ expect(k).toBe('sap_activate:ZBAR:');
12
+ });
13
+ it('keys by tool::type when only type present', () => {
14
+ const k = objectKeyFromInput('sap_search_object', { type: 'CLAS' });
15
+ expect(k).toBe('sap_search_object::CLAS');
16
+ });
17
+ it('repeated calls with same name+type return identical keys (failure accumulation)', () => {
18
+ const k1 = objectKeyFromInput('sap_get_source', { name: 'ZBAZ', type: 'CLAS' });
19
+ const k2 = objectKeyFromInput('sap_get_source', { name: 'ZBAZ', type: 'CLAS' });
20
+ expect(k1).toBe(k2);
21
+ });
22
+ });
23
+ describe('object_name / object_type alternates', () => {
24
+ it('falls back to object_name when name absent', () => {
25
+ const k = objectKeyFromInput('sap_atc_run', { object_name: 'ZQUX', object_type: 'PROG' });
26
+ expect(k).toBe('sap_atc_run:ZQUX:PROG');
27
+ });
28
+ it('mixes name + object_type when only one alternate present', () => {
29
+ const k = objectKeyFromInput('sap_atc_run', { name: 'ZA', object_type: 'CLAS' });
30
+ expect(k).toBe('sap_atc_run:ZA:CLAS');
31
+ });
32
+ });
33
+ describe('argument-only tools (the bug fix)', () => {
34
+ it('different sap_sql_query queries get DIFFERENT keys', () => {
35
+ const k1 = objectKeyFromInput('sap_sql_query', { query: 'SELECT * FROM /SDF/MON_RECS' });
36
+ const k2 = objectKeyFromInput('sap_sql_query', { query: 'SELECT * FROM /SDF/MON_HEADER' });
37
+ const k3 = objectKeyFromInput('sap_sql_query', { query: 'SELECT * FROM ZQLREC' });
38
+ expect(k1).not.toBe(k2);
39
+ expect(k2).not.toBe(k3);
40
+ expect(k1).not.toBe(k3);
41
+ });
42
+ it('same sap_sql_query repeated returns the SAME key (still cap-able)', () => {
43
+ const args = { query: 'SELECT * FROM /SDF/MON_RECS' };
44
+ const k1 = objectKeyFromInput('sap_sql_query', args);
45
+ const k2 = objectKeyFromInput('sap_sql_query', args);
46
+ expect(k1).toBe(k2);
47
+ });
48
+ it('hash-fallback keys are namespaced by tool name', () => {
49
+ const sql = objectKeyFromInput('sap_sql_query', { query: 'SELECT 1 FROM DUAL' });
50
+ const search = objectKeyFromInput('sap_search_object', { query: 'SELECT 1 FROM DUAL' });
51
+ expect(sql).not.toBe(search);
52
+ expect(sql.startsWith('sap_sql_query:#')).toBe(true);
53
+ expect(search.startsWith('sap_search_object:#')).toBe(true);
54
+ });
55
+ it('empty args still produce a stable key per tool', () => {
56
+ const k1 = objectKeyFromInput('some_tool', {});
57
+ const k2 = objectKeyFromInput('some_tool', {});
58
+ expect(k1).toBe(k2);
59
+ expect(k1.startsWith('some_tool:#')).toBe(true);
60
+ });
61
+ });
62
+ describe('regression — the original bug', () => {
63
+ it('three different probe queries do NOT collapse onto one key', () => {
64
+ // Reproduce the exact failure from the live /abap-cca DISCOVER trip.
65
+ const probes = [
66
+ { query: 'SELECT * FROM "/SDF/MON_RECS"' },
67
+ { query: 'SELECT * FROM "/SDF/MON_HEADER"' },
68
+ { query: 'SELECT * FROM ZQLREC' },
69
+ ];
70
+ const keys = probes.map((p) => objectKeyFromInput('sap_sql_query', p));
71
+ const unique = new Set(keys);
72
+ // Pre-fix: all three keys were `sap_sql_query::` → set size 1 → cap tripped after 3 distinct probes.
73
+ // Post-fix: each probe gets its own hash → set size 3.
74
+ expect(unique.size).toBe(3);
75
+ });
76
+ });
77
+ });