@nathapp/nax 0.49.3 → 0.49.6

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.
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { AgentAdapter } from "../agents";
10
+ import { buildSessionName } from "../agents/acp/adapter";
10
11
  import type { ModelTier, NaxConfig } from "../config";
11
12
  import { resolveModel } from "../config";
12
13
  import { resolvePermissions } from "../config/permissions";
@@ -14,15 +15,23 @@ import type { getLogger } from "../logger";
14
15
  import type { UserStory } from "../prd";
15
16
  import { autoCommitIfDirty, captureGitRef } from "../utils/git";
16
17
  import {
18
+ type parseBunTestOutput as ParseBunTestOutputType,
17
19
  type RectificationState,
18
- executeWithTimeout,
19
- parseBunTestOutput,
20
- shouldRetryRectification,
20
+ executeWithTimeout as _executeWithTimeout,
21
+ parseBunTestOutput as _parseBunTestOutput,
22
+ shouldRetryRectification as _shouldRetryRectification,
21
23
  } from "../verification";
22
24
  import { cleanupProcessTree } from "./cleanup";
23
25
  import { verifyImplementerIsolation } from "./isolation";
24
26
  import { buildImplementerRectificationPrompt } from "./prompts";
25
27
 
28
+ /** Injectable deps for testability — avoids mock.module() contamination */
29
+ export const _rectificationGateDeps = {
30
+ executeWithTimeout: _executeWithTimeout,
31
+ parseBunTestOutput: _parseBunTestOutput,
32
+ shouldRetryRectification: _shouldRetryRectification,
33
+ };
34
+
26
35
  /**
27
36
  * Run full test suite gate before verifier session (v0.11 Rectification).
28
37
  */
@@ -49,11 +58,13 @@ export async function runFullSuiteGate(
49
58
  timeout: fullSuiteTimeout,
50
59
  });
51
60
 
52
- const fullSuiteResult = await executeWithTimeout(testCmd, fullSuiteTimeout, undefined, { cwd: workdir });
61
+ const fullSuiteResult = await _rectificationGateDeps.executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
62
+ cwd: workdir,
63
+ });
53
64
  const fullSuitePassed = fullSuiteResult.success && fullSuiteResult.exitCode === 0;
54
65
 
55
66
  if (!fullSuitePassed && fullSuiteResult.output) {
56
- const testSummary = parseBunTestOutput(fullSuiteResult.output);
67
+ const testSummary = _rectificationGateDeps.parseBunTestOutput(fullSuiteResult.output);
57
68
 
58
69
  if (testSummary.failed > 0) {
59
70
  return await runRectificationLoop(
@@ -117,7 +128,7 @@ async function runRectificationLoop(
117
128
  contextMarkdown: string | undefined,
118
129
  lite: boolean,
119
130
  logger: ReturnType<typeof getLogger>,
120
- testSummary: ReturnType<typeof parseBunTestOutput>,
131
+ testSummary: ReturnType<typeof _parseBunTestOutput>,
121
132
  rectificationConfig: NonNullable<NaxConfig["execution"]["rectification"]>,
122
133
  testCmd: string,
123
134
  fullSuiteTimeout: number,
@@ -135,9 +146,19 @@ async function runRectificationLoop(
135
146
  passedTests: testSummary.passed,
136
147
  });
137
148
 
138
- while (shouldRetryRectification(rectificationState, rectificationConfig)) {
149
+ // Build session name once so all rectification attempts share the same ACP session.
150
+ // This preserves full conversation context across retries (the agent knows what it already tried).
151
+ const rectificationSessionName = buildSessionName(workdir, featureName, story.id, "implementer");
152
+ logger.debug("tdd", "Rectification session name (shared across all attempts)", {
153
+ storyId: story.id,
154
+ sessionName: rectificationSessionName,
155
+ });
156
+
157
+ while (_rectificationGateDeps.shouldRetryRectification(rectificationState, rectificationConfig)) {
139
158
  rectificationState.attempt++;
140
159
 
160
+ const isLastAttempt = rectificationState.attempt >= rectificationConfig.maxRetries;
161
+
141
162
  logger.info(
142
163
  "tdd",
143
164
  `-> Implementer rectification attempt ${rectificationState.attempt}/${rectificationConfig.maxRetries}`,
@@ -166,6 +187,12 @@ async function runRectificationLoop(
166
187
  featureName,
167
188
  storyId: story.id,
168
189
  sessionRole: "implementer",
190
+ // Reuse the same ACP session across all rectification attempts so the agent
191
+ // retains full conversation context (knows what it already tried).
192
+ acpSessionName: rectificationSessionName,
193
+ // Keep session open until the last attempt — the session sweep at run end
194
+ // will handle cleanup. On the last attempt, let the adapter close normally.
195
+ keepSessionOpen: !isLastAttempt,
169
196
  });
170
197
 
171
198
  if (!rectifyResult.success && rectifyResult.pid) {
@@ -201,7 +228,9 @@ async function runRectificationLoop(
201
228
  break;
202
229
  }
203
230
 
204
- const retryFullSuite = await executeWithTimeout(testCmd, fullSuiteTimeout, undefined, { cwd: workdir });
231
+ const retryFullSuite = await _rectificationGateDeps.executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
232
+ cwd: workdir,
233
+ });
205
234
  const retrySuitePassed = retryFullSuite.success && retryFullSuite.exitCode === 0;
206
235
 
207
236
  if (retrySuitePassed) {
@@ -213,7 +242,7 @@ async function runRectificationLoop(
213
242
  }
214
243
 
215
244
  if (retryFullSuite.output) {
216
- const newTestSummary = parseBunTestOutput(retryFullSuite.output);
245
+ const newTestSummary = _rectificationGateDeps.parseBunTestOutput(retryFullSuite.output);
217
246
  rectificationState.currentFailures = newTestSummary.failed;
218
247
  testSummary.failures = newTestSummary.failures;
219
248
  testSummary.failed = newTestSummary.failed;
@@ -227,7 +256,9 @@ async function runRectificationLoop(
227
256
  });
228
257
  }
229
258
 
230
- const finalFullSuite = await executeWithTimeout(testCmd, fullSuiteTimeout, undefined, { cwd: workdir });
259
+ const finalFullSuite = await _rectificationGateDeps.executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
260
+ cwd: workdir,
261
+ });
231
262
  const finalSuitePassed = finalFullSuite.success && finalFullSuite.exitCode === 0;
232
263
 
233
264
  if (!finalSuitePassed) {
@@ -12,17 +12,39 @@ import { getLogger } from "../logger";
12
12
  import type { UserStory } from "../prd";
13
13
  import { PromptBuilder } from "../prompts";
14
14
  import { autoCommitIfDirty as _autoCommitIfDirtyFn } from "../utils/git";
15
- import { cleanupProcessTree } from "./cleanup";
16
-
15
+ import { captureGitRef as _captureGitRef } from "../utils/git";
16
+ import { cleanupProcessTree as _cleanupProcessTree } from "./cleanup";
17
17
  /**
18
18
  * Injectable dependencies for session-runner — allows tests to mock
19
19
  * autoCommitIfDirty without going through internal git deps.
20
20
  * @internal
21
21
  */
22
+ import {
23
+ getChangedFiles as _getChangedFiles,
24
+ verifyImplementerIsolation as _verifyImplementerIsolation,
25
+ verifyTestWriterIsolation as _verifyTestWriterIsolation,
26
+ } from "./isolation";
27
+
22
28
  export const _sessionRunnerDeps = {
23
29
  autoCommitIfDirty: _autoCommitIfDirtyFn,
30
+ spawn: Bun.spawn as typeof Bun.spawn,
31
+ getChangedFiles: _getChangedFiles,
32
+ verifyTestWriterIsolation: _verifyTestWriterIsolation,
33
+ verifyImplementerIsolation: _verifyImplementerIsolation,
34
+ captureGitRef: _captureGitRef,
35
+ cleanupProcessTree: _cleanupProcessTree,
36
+ buildPrompt: null as
37
+ | null
38
+ | ((
39
+ role: TddSessionRole,
40
+ config: NaxConfig,
41
+ story: UserStory,
42
+ workdir: string,
43
+ contextMarkdown?: string,
44
+ lite?: boolean,
45
+ constitution?: string,
46
+ ) => Promise<string>),
24
47
  };
25
- import { getChangedFiles, verifyImplementerIsolation, verifyTestWriterIsolation } from "./isolation";
26
48
  import type { IsolationCheck } from "./types";
27
49
  import type { TddSessionResult, TddSessionRole } from "./types";
28
50
 
@@ -53,7 +75,7 @@ export async function rollbackToRef(workdir: string, ref: string): Promise<void>
53
75
  const logger = getLogger();
54
76
  logger.warn("tdd", "Rolling back git changes", { ref });
55
77
 
56
- const resetProc = Bun.spawn(["git", "reset", "--hard", ref], {
78
+ const resetProc = _sessionRunnerDeps.spawn(["git", "reset", "--hard", ref], {
57
79
  cwd: workdir,
58
80
  stdout: "pipe",
59
81
  stderr: "pipe",
@@ -66,7 +88,7 @@ export async function rollbackToRef(workdir: string, ref: string): Promise<void>
66
88
  throw new Error(`Git rollback failed: ${stderr}`);
67
89
  }
68
90
 
69
- const cleanProc = Bun.spawn(["git", "clean", "-fd"], {
91
+ const cleanProc = _sessionRunnerDeps.spawn(["git", "clean", "-fd"], {
70
92
  cwd: workdir,
71
93
  stdout: "pipe",
72
94
  stderr: "pipe",
@@ -98,41 +120,51 @@ export async function runTddSession(
98
120
  ): Promise<TddSessionResult> {
99
121
  const startTime = Date.now();
100
122
 
101
- // Build prompt based on role and mode (lite vs strict)
123
+ // Build prompt use injectable buildPrompt if set, otherwise default PromptBuilder
102
124
  let prompt: string;
103
- switch (role) {
104
- case "test-writer":
105
- prompt = await PromptBuilder.for("test-writer", { isolation: lite ? "lite" : "strict" })
106
- .withLoader(workdir, config)
107
- .story(story)
108
- .context(contextMarkdown)
109
- .constitution(constitution)
110
- .testCommand(config.quality?.commands?.test)
111
- .build();
112
- break;
113
- case "implementer":
114
- prompt = await PromptBuilder.for("implementer", { variant: lite ? "lite" : "standard" })
115
- .withLoader(workdir, config)
116
- .story(story)
117
- .context(contextMarkdown)
118
- .constitution(constitution)
119
- .testCommand(config.quality?.commands?.test)
120
- .build();
121
- break;
122
- case "verifier":
123
- prompt = await PromptBuilder.for("verifier")
124
- .withLoader(workdir, config)
125
- .story(story)
126
- .context(contextMarkdown)
127
- .constitution(constitution)
128
- .testCommand(config.quality?.commands?.test)
129
- .build();
130
- break;
125
+ if (_sessionRunnerDeps.buildPrompt) {
126
+ prompt = await _sessionRunnerDeps.buildPrompt(role, config, story, workdir, contextMarkdown, lite, constitution);
127
+ } else {
128
+ switch (role) {
129
+ case "test-writer":
130
+ prompt = await PromptBuilder.for("test-writer", { isolation: lite ? "lite" : "strict" })
131
+ .withLoader(workdir, config)
132
+ .story(story)
133
+ .context(contextMarkdown)
134
+ .constitution(constitution)
135
+ .testCommand(config.quality?.commands?.test)
136
+ .build();
137
+ break;
138
+ case "implementer":
139
+ prompt = await PromptBuilder.for("implementer", { variant: lite ? "lite" : "standard" })
140
+ .withLoader(workdir, config)
141
+ .story(story)
142
+ .context(contextMarkdown)
143
+ .constitution(constitution)
144
+ .testCommand(config.quality?.commands?.test)
145
+ .build();
146
+ break;
147
+ case "verifier":
148
+ prompt = await PromptBuilder.for("verifier")
149
+ .withLoader(workdir, config)
150
+ .story(story)
151
+ .context(contextMarkdown)
152
+ .constitution(constitution)
153
+ .testCommand(config.quality?.commands?.test)
154
+ .build();
155
+ break;
156
+ }
131
157
  }
132
158
 
133
159
  const logger = getLogger();
134
160
  logger.info("tdd", `-> Session: ${role}`, { role, storyId: story.id, lite });
135
161
 
162
+ // When rectification is enabled, keep the implementer session open after it finishes.
163
+ // The rectification gate uses the same session name (buildSessionName + "implementer")
164
+ // and will resume it directly — so the implementer retains full context of what it built.
165
+ // The session sweep (or the last rectification attempt) handles final cleanup.
166
+ const keepSessionOpen = role === "implementer" && (config.execution.rectification?.enabled ?? false);
167
+
136
168
  // Run the agent
137
169
  const result = await agent.run({
138
170
  prompt,
@@ -147,11 +179,12 @@ export async function runTddSession(
147
179
  featureName,
148
180
  storyId: story.id,
149
181
  sessionRole: role,
182
+ keepSessionOpen,
150
183
  });
151
184
 
152
185
  // BUG-21 Fix: Clean up orphaned child processes if agent failed
153
186
  if (!result.success && result.pid) {
154
- await cleanupProcessTree(result.pid);
187
+ await _sessionRunnerDeps.cleanupProcessTree(result.pid);
155
188
  }
156
189
 
157
190
  if (result.success) {
@@ -178,14 +211,14 @@ export async function runTddSession(
178
211
  if (!skipIsolation) {
179
212
  if (role === "test-writer") {
180
213
  const allowedPaths = config.tdd.testWriterAllowedPaths ?? ["src/index.ts", "src/**/index.ts"];
181
- isolation = await verifyTestWriterIsolation(workdir, beforeRef, allowedPaths);
214
+ isolation = await _sessionRunnerDeps.verifyTestWriterIsolation(workdir, beforeRef, allowedPaths);
182
215
  } else if (role === "implementer" || role === "verifier") {
183
- isolation = await verifyImplementerIsolation(workdir, beforeRef);
216
+ isolation = await _sessionRunnerDeps.verifyImplementerIsolation(workdir, beforeRef);
184
217
  }
185
218
  }
186
219
 
187
220
  // Get changed files
188
- const filesChanged = await getChangedFiles(workdir, beforeRef);
221
+ const filesChanged = await _sessionRunnerDeps.getChangedFiles(workdir, beforeRef);
189
222
 
190
223
  const durationMs = Date.now() - startTime;
191
224
 
@@ -9,6 +9,9 @@ import type { Subprocess } from "bun";
9
9
  import { errorMessage } from "../utils/errors";
10
10
  import type { TestExecutionResult } from "./types";
11
11
 
12
+ /** Injectable deps for testability — mock _executorDeps.spawn instead of global Bun.spawn */
13
+ export const _executorDeps = { spawn: Bun.spawn as typeof Bun.spawn };
14
+
12
15
  /**
13
16
  * Drain stdout+stderr from a killed Bun subprocess with a hard deadline.
14
17
  *
@@ -92,7 +95,7 @@ export async function executeWithTimeout(
92
95
  const gracePeriodMs = options?.gracePeriodMs ?? 5000;
93
96
  const drainTimeoutMs = options?.drainTimeoutMs ?? 2000;
94
97
 
95
- const proc = Bun.spawn([shell, "-c", command], {
98
+ const proc = _executorDeps.spawn([shell, "-c", command], {
96
99
  stdout: "pipe",
97
100
  stderr: "pipe",
98
101
  env: env || normalizeEnvironment(process.env as Record<string, string | undefined>),
@@ -11,6 +11,9 @@ import { getLogger } from "../../logger";
11
11
  import type { IVerificationStrategy, VerifyContext, VerifyResult } from "../orchestrator-types";
12
12
  import { makeFailResult, makePassResult, makeSkippedResult } from "../orchestrator-types";
13
13
 
14
+ /** Injectable deps for testability */
15
+ export const _acceptanceDeps = { spawn: Bun.spawn as typeof Bun.spawn };
16
+
14
17
  function parseFailedACs(output: string): string[] {
15
18
  const failed: string[] = [];
16
19
  for (const line of output.split("\n")) {
@@ -51,7 +54,7 @@ export class AcceptanceStrategy implements IVerificationStrategy {
51
54
 
52
55
  const start = Date.now();
53
56
  const timeoutMs = ctx.timeoutSeconds * 1000;
54
- const proc = Bun.spawn(["bun", "test", testPath], {
57
+ const proc = _acceptanceDeps.spawn(["bun", "test", testPath], {
55
58
  cwd: ctx.workdir,
56
59
  stdout: "pipe",
57
60
  stderr: "pipe",