gsd-pi 2.71.0-dev.7a61d89 → 2.71.0-dev.e17e0ce

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 (75) hide show
  1. package/dist/resources/extensions/gsd/auto/phases.js +1 -1
  2. package/dist/resources/extensions/gsd/auto/session.js +3 -0
  3. package/dist/resources/extensions/gsd/auto-model-selection.js +10 -2
  4. package/dist/resources/extensions/gsd/auto-start.js +12 -5
  5. package/dist/resources/extensions/gsd/auto-worktree.js +1 -1
  6. package/dist/resources/extensions/gsd/commands/handlers/core.js +11 -0
  7. package/dist/resources/extensions/gsd/session-model-override.js +25 -0
  8. package/dist/web/standalone/.next/BUILD_ID +1 -1
  9. package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
  10. package/dist/web/standalone/.next/build-manifest.json +2 -2
  11. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  12. package/dist/web/standalone/.next/required-server-files.json +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/index.html +1 -1
  30. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
  37. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  38. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  39. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  40. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  41. package/dist/web/standalone/server.js +1 -1
  42. package/package.json +1 -1
  43. package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.d.ts +2 -0
  44. package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.d.ts.map +1 -0
  45. package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.js +64 -0
  46. package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.js.map +1 -0
  47. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/core/model-resolver.js +22 -18
  49. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.d.ts +2 -0
  51. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.d.ts.map +1 -0
  52. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.js +13 -0
  53. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.js.map +1 -0
  54. package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.d.ts +4 -0
  55. package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
  56. package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.js +24 -2
  57. package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.js.map +1 -1
  58. package/packages/pi-coding-agent/src/core/model-resolver-initial-model-auth.test.ts +78 -0
  59. package/packages/pi-coding-agent/src/core/model-resolver.ts +22 -18
  60. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/login-dialog.test.ts +24 -0
  61. package/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts +30 -2
  62. package/src/resources/extensions/gsd/auto/loop-deps.ts +2 -0
  63. package/src/resources/extensions/gsd/auto/phases.ts +2 -0
  64. package/src/resources/extensions/gsd/auto/session.ts +3 -0
  65. package/src/resources/extensions/gsd/auto-model-selection.ts +9 -1
  66. package/src/resources/extensions/gsd/auto-start.ts +12 -5
  67. package/src/resources/extensions/gsd/auto-worktree.ts +1 -1
  68. package/src/resources/extensions/gsd/commands/handlers/core.ts +12 -0
  69. package/src/resources/extensions/gsd/session-model-override.ts +36 -0
  70. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +11 -9
  71. package/src/resources/extensions/gsd/tests/integration/auto-worktree-milestone-merge.test.ts +66 -1
  72. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +36 -51
  73. package/src/resources/extensions/gsd/tests/session-model-override.test.ts +35 -0
  74. /package/dist/web/standalone/.next/static/{ug91LJa0m7OdzrTVaz_48 → cYPZv_bAhZk2ms-Pz6vsY}/_buildManifest.js +0 -0
  75. /package/dist/web/standalone/.next/static/{ug91LJa0m7OdzrTVaz_48 → cYPZv_bAhZk2ms-Pz6vsY}/_ssgManifest.js +0 -0
@@ -0,0 +1,24 @@
1
+ import { describe, test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildAuthUrlPresentation } from "../login-dialog.js";
4
+
5
+ describe("LoginDialogComponent", () => {
6
+ test("shows the full OAuth URL when the hyperlink label is truncated", () => {
7
+ const presentation = buildAuthUrlPresentation(
8
+ "https://auth.example.com/device?code=ABCD-1234&callback=oauth&state=needs-full-visibility",
9
+ 52,
10
+ );
11
+
12
+ assert.notEqual(
13
+ presentation.displayUrl,
14
+ "https://auth.example.com/device?code=ABCD-1234&callback=oauth&state=needs-full-visibility",
15
+ "narrow terminals should still truncate the hyperlink label",
16
+ );
17
+ assert.ok(presentation.fullUrlLines.length > 1, "truncated URLs should expose wrapped full-url lines");
18
+ assert.match(presentation.fullUrlLines[0] ?? "", /https:\/\/auth\.example\.com\/device\?code=ABCD-1234&/);
19
+ assert.match(
20
+ presentation.fullUrlLines[presentation.fullUrlLines.length - 1] ?? "",
21
+ /state=needs-full-visibility/,
22
+ );
23
+ });
24
+ });
@@ -7,6 +7,27 @@ import { theme } from "../theme/theme.js";
7
7
  import { DynamicBorder } from "./dynamic-border.js";
8
8
  import { keyHint } from "./keybinding-hints.js";
9
9
 
10
+ function wrapPlainText(text: string, width: number): string[] {
11
+ const lines: string[] = [];
12
+ const safeWidth = Math.max(1, width);
13
+ for (let idx = 0; idx < text.length; idx += safeWidth) {
14
+ lines.push(text.slice(idx, idx + safeWidth));
15
+ }
16
+ return lines.length > 0 ? lines : [""];
17
+ }
18
+
19
+ export function buildAuthUrlPresentation(url: string, terminalColumns: number): {
20
+ displayUrl: string;
21
+ fullUrlLines: string[];
22
+ } {
23
+ const maxUrlWidth = Math.max(20, terminalColumns - 4);
24
+ const displayUrl = truncateToWidth(url, maxUrlWidth);
25
+ return {
26
+ displayUrl,
27
+ fullUrlLines: displayUrl === url ? [] : wrapPlainText(url, maxUrlWidth),
28
+ };
29
+ }
30
+
10
31
  /**
11
32
  * Login dialog component - replaces editor during OAuth login flow.
12
33
  *
@@ -124,14 +145,21 @@ export class LoginDialogComponent extends Container implements Focusable {
124
145
 
125
146
  // Truncate the visible URL text so it never wraps (which would break
126
147
  // the OSC 8 hyperlink). The full URL is still the link target.
127
- const maxUrlWidth = Math.max(20, this.tui.terminal.columns - 4);
128
- const displayUrl = truncateToWidth(url, maxUrlWidth);
148
+ const { displayUrl, fullUrlLines } = buildAuthUrlPresentation(url, this.tui.terminal.columns);
129
149
  const urlLink = `\x1b]8;;${url}\x07${theme.fg("accent", displayUrl)}\x1b]8;;\x07`;
130
150
  this.contentContainer.addChild(new Text(urlLink, 1, 0));
131
151
 
132
152
  const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
133
153
  this.contentContainer.addChild(new Text(theme.fg("dim", clickHint), 1, 0));
134
154
 
155
+ if (fullUrlLines.length > 0) {
156
+ this.contentContainer.addChild(new Spacer(1));
157
+ this.contentContainer.addChild(new Text(theme.fg("dim", "Full URL:"), 1, 0));
158
+ for (const line of fullUrlLines) {
159
+ this.contentContainer.addChild(new Text(theme.fg("dim", line), 1, 0));
160
+ }
161
+ }
162
+
135
163
  if (instructions) {
136
164
  this.contentContainer.addChild(new Spacer(1));
137
165
  this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0));
@@ -211,6 +211,8 @@ export interface LoopDeps {
211
211
  verbose: boolean,
212
212
  startModel: { provider: string; id: string } | null,
213
213
  retryContext?: { isRetry: boolean; previousTier?: string },
214
+ isAutoMode?: boolean,
215
+ sessionModelOverride?: { provider: string; id: string } | null,
214
216
  ) => Promise<{
215
217
  routing: { tier: string; modelDowngraded: boolean } | null;
216
218
  appliedModel: { provider: string; id: string } | null;
@@ -1183,6 +1183,8 @@ export async function runUnitPhase(
1183
1183
  s.verbose,
1184
1184
  s.autoModeStartModel,
1185
1185
  sidecarItem ? undefined : { isRetry, previousTier },
1186
+ undefined,
1187
+ s.manualSessionModelOverride,
1186
1188
  );
1187
1189
  s.currentUnitRouting =
1188
1190
  modelResult.routing as AutoSession["currentUnitRouting"];
@@ -111,6 +111,8 @@ export class AutoSession {
111
111
 
112
112
  // ── Model state ──────────────────────────────────────────────────────────
113
113
  autoModeStartModel: StartModel | null = null;
114
+ /** Explicit /gsd model pin captured at bootstrap (session-scoped policy override). */
115
+ manualSessionModelOverride: StartModel | null = null;
114
116
  currentUnitModel: Model<Api> | null = null;
115
117
  /** Fully-qualified model ID (provider/id) set after selectAndApplyModel + hook overrides (#2899). */
116
118
  currentDispatchedModelId: string | null = null;
@@ -222,6 +224,7 @@ export class AutoSession {
222
224
 
223
225
  // Model
224
226
  this.autoModeStartModel = null;
227
+ this.manualSessionModelOverride = null;
225
228
  this.currentUnitModel = null;
226
229
  this.currentDispatchedModelId = null;
227
230
  this.originalModelId = null;
@@ -14,6 +14,7 @@ import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js";
14
14
  import { resolveModelForComplexity, escalateTier, getEligibleModels, loadCapabilityOverrides, adjustToolSet, filterToolsForProvider } from "./model-router.js";
15
15
  import { getLedger, getProjectTotals } from "./metrics.js";
16
16
  import { unitPhaseLabel } from "./auto-dashboard.js";
17
+ import { getSessionModelOverride } from "./session-model-override.js";
17
18
 
18
19
  export interface ModelSelectionResult {
19
20
  /** Routing metadata for metrics recording */
@@ -72,8 +73,15 @@ export async function selectAndApplyModel(
72
73
  /** When false (interactive/guided-flow), skip dynamic routing and use the session model.
73
74
  * Dynamic routing only applies in auto-mode where cost optimization is expected. (#3962) */
74
75
  isAutoMode = true,
76
+ /** Explicit /gsd model pin captured at bootstrap for long-running auto loops. */
77
+ sessionModelOverride?: { provider: string; id: string } | null,
75
78
  ): Promise<ModelSelectionResult> {
76
- const modelConfig = resolvePreferredModelConfig(unitType, autoModeStartModel, isAutoMode);
79
+ const effectiveSessionModelOverride = sessionModelOverride === undefined
80
+ ? getSessionModelOverride(ctx.sessionManager.getSessionId())
81
+ : (sessionModelOverride ?? undefined);
82
+ const modelConfig = effectiveSessionModelOverride
83
+ ? undefined
84
+ : resolvePreferredModelConfig(unitType, autoModeStartModel, isAutoMode);
77
85
  let routing: { tier: string; modelDowngraded: boolean } | null = null;
78
86
  let appliedModel: Model<Api> | null = null;
79
87
 
@@ -85,6 +85,7 @@ import { sep as pathSep } from "node:path";
85
85
  import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
86
86
  import { resolveDefaultSessionModel, resolveDynamicRoutingConfig } from "./preferences-models.js";
87
87
  import type { WorktreeResolver } from "./worktree-resolver.js";
88
+ import { getSessionModelOverride } from "./session-model-override.js";
88
89
 
89
90
  export interface BootstrapDeps {
90
91
  shouldUseWorktreeIsolation: () => boolean;
@@ -266,12 +267,17 @@ export async function bootstrapAutoSession(
266
267
  // Capture the user's session model before guided-flow dispatch can apply a
267
268
  // phase-specific planning model for a discuss turn (#2829).
268
269
  //
269
- // GSD PREFERENCES.md takes priority over the session model from settings.json
270
- // (#3517). The session model (ctx.model) comes from findInitialModel() which
271
- // reads defaultProvider/defaultModel from ~/.gsd/agent/settings.json. When
272
- // the user has explicit model preferences in PREFERENCES.md, those should win.
270
+ // Precedence:
271
+ // 1) Explicit session override via /gsd model (this session)
272
+ // 2) GSD model preferences from PREFERENCES.md
273
+ // 3) Current session model from settings/session restore
274
+ //
275
+ // This preserves #3517 defaults while honoring explicit runtime model
276
+ // selection for subsequent /gsd runs in the same session.
277
+ const manualSessionOverride = getSessionModelOverride(ctx.sessionManager.getSessionId());
273
278
  const preferredModel = resolveDefaultSessionModel(ctx.model?.provider);
274
- const startModelSnapshot = preferredModel
279
+ const startModelSnapshot = manualSessionOverride
280
+ ?? preferredModel
275
281
  ?? (ctx.model
276
282
  ? { provider: ctx.model.provider, id: ctx.model.id }
277
283
  : null);
@@ -731,6 +737,7 @@ export async function bootstrapAutoSession(
731
737
  id: startModelSnapshot.id,
732
738
  };
733
739
  }
740
+ s.manualSessionModelOverride = manualSessionOverride ?? null;
734
741
 
735
742
  // Apply worker model override from parallel orchestrator (#worker-model).
736
743
  // GSD_WORKER_MODEL is injected by the coordinator when parallel.worker_model
@@ -2043,7 +2043,7 @@ export function mergeMilestoneToMain(
2043
2043
  // 12. Remove worktree directory first (must happen before branch deletion)
2044
2044
  try {
2045
2045
  removeWorktree(originalBasePath_, milestoneId, {
2046
- branch: null as unknown as string,
2046
+ branch: milestoneBranch,
2047
2047
  deleteBranch: false,
2048
2048
  });
2049
2049
  } catch (err) {
@@ -8,6 +8,7 @@ import { ensurePreferencesFile, handlePrefs, handlePrefsMode, handlePrefsWizard
8
8
  import { runEnvironmentChecks } from "../../doctor-environment.js";
9
9
  import { deriveState } from "../../state.js";
10
10
  import { handleCmux } from "../../commands-cmux.js";
11
+ import { setSessionModelOverride } from "../../session-model-override.js";
11
12
  import { projectRoot } from "../context.js";
12
13
  import { formattedShortcutPair } from "../../shortcut-defs.js";
13
14
 
@@ -336,6 +337,17 @@ async function handleModel(trimmedArgs: string, ctx: ExtensionCommandContext, pi
336
337
  return;
337
338
  }
338
339
 
340
+ // /gsd model is an explicit per-session pin for GSD dispatches.
341
+ // This is captured at auto bootstrap so it survives internal session
342
+ // switches during /gsd auto and /gsd next runs.
343
+ const sessionId = ctx.sessionManager?.getSessionId?.();
344
+ if (sessionId) {
345
+ setSessionModelOverride(sessionId, {
346
+ provider: targetModel.provider,
347
+ id: targetModel.id,
348
+ });
349
+ }
350
+
339
351
  ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}`, "info");
340
352
  }
341
353
 
@@ -0,0 +1,36 @@
1
+ export interface SessionModelOverride {
2
+ provider: string;
3
+ id: string;
4
+ }
5
+
6
+ const sessionOverrides = new Map<string, SessionModelOverride>();
7
+
8
+ function normalizeSessionId(sessionId: string): string {
9
+ return typeof sessionId === "string" ? sessionId.trim() : "";
10
+ }
11
+
12
+ export function setSessionModelOverride(
13
+ sessionId: string,
14
+ override: SessionModelOverride,
15
+ ): void {
16
+ const key = normalizeSessionId(sessionId);
17
+ if (!key) return;
18
+ sessionOverrides.set(key, {
19
+ provider: override.provider,
20
+ id: override.id,
21
+ });
22
+ }
23
+
24
+ export function getSessionModelOverride(
25
+ sessionId: string,
26
+ ): SessionModelOverride | undefined {
27
+ const key = normalizeSessionId(sessionId);
28
+ if (!key) return undefined;
29
+ return sessionOverrides.get(key);
30
+ }
31
+
32
+ export function clearSessionModelOverride(sessionId: string): void {
33
+ const key = normalizeSessionId(sessionId);
34
+ if (!key) return;
35
+ sessionOverrides.delete(key);
36
+ }
@@ -7,9 +7,8 @@ const sourcePath = join(import.meta.dirname, "..", "auto-start.ts");
7
7
  const source = readFileSync(sourcePath, "utf-8");
8
8
 
9
9
  test("bootstrapAutoSession snapshots ctx.model before guided-flow entry (#2829)", () => {
10
- // #3517 changed the snapshot to prefer GSD preferences, but the ordering
11
- // guarantee still holds: the snapshot must be built before guided-flow.
12
- const snapshotIdx = source.indexOf("const startModelSnapshot = preferredModel");
10
+ // The snapshot ordering guarantee still holds: build snapshot before guided-flow.
11
+ const snapshotIdx = source.indexOf("const startModelSnapshot = manualSessionOverride");
13
12
  assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot model at bootstrap start");
14
13
 
15
14
  const firstDiscussIdx = source.indexOf('await showSmartEntry(ctx, pi, base, { step: requestedStepMode });');
@@ -29,8 +28,11 @@ test("bootstrapAutoSession restores autoModeStartModel from the early snapshot (
29
28
  assert.ok(snapshotRefIdx > -1, "autoModeStartModel should be restored from startModelSnapshot");
30
29
  });
31
30
 
32
- test("bootstrapAutoSession prefers GSD PREFERENCES.md over settings.json for start model (#3517)", () => {
33
- // resolveDefaultSessionModel() should be called before the snapshot is built
31
+ test("bootstrapAutoSession checks manual session override before preferences", () => {
32
+ const manualIdx = source.indexOf("const manualSessionOverride = getSessionModelOverride(");
33
+ assert.ok(manualIdx > -1, "auto-start.ts should read session model override first");
34
+
35
+ // resolveDefaultSessionModel() should still be called for fallback behavior
34
36
  const preferredIdx = source.indexOf("const preferredModel = resolveDefaultSessionModel(");
35
37
  assert.ok(preferredIdx > -1, "auto-start.ts should call resolveDefaultSessionModel()");
36
38
 
@@ -38,11 +40,11 @@ test("bootstrapAutoSession prefers GSD PREFERENCES.md over settings.json for sta
38
40
  const withProviderIdx = source.indexOf("resolveDefaultSessionModel(ctx.model?.provider)");
39
41
  assert.ok(withProviderIdx > -1, "auto-start.ts should pass ctx.model?.provider for bare ID resolution");
40
42
 
41
- const snapshotIdx = source.indexOf("const startModelSnapshot = preferredModel");
42
- assert.ok(snapshotIdx > -1, "startModelSnapshot should use preferredModel when available");
43
+ const snapshotIdx = source.indexOf("const startModelSnapshot = manualSessionOverride");
44
+ assert.ok(snapshotIdx > -1, "startModelSnapshot should prefer manual session override");
43
45
 
44
46
  assert.ok(
45
- preferredIdx < snapshotIdx,
46
- "resolveDefaultSessionModel() must be called before building startModelSnapshot",
47
+ manualIdx < snapshotIdx && preferredIdx < snapshotIdx,
48
+ "manual override and preference fallback must be resolved before building startModelSnapshot",
47
49
  );
48
50
  });
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { describe, test, afterEach } from "node:test";
14
14
  import assert from "node:assert/strict";
15
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync, readFileSync } from "node:fs";
15
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync, readFileSync, symlinkSync, unlinkSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { tmpdir } from "node:os";
18
18
  import { execSync } from "node:child_process";
@@ -44,6 +44,27 @@ function createTempRepo(): string {
44
44
  return dir;
45
45
  }
46
46
 
47
+ function createTempRepoWithExternalGsd(): { repo: string; externalState: string } {
48
+ const realTmp = realpathSync(tmpdir());
49
+ const repo = realpathSync(mkdtempSync(join(realTmp, "wt-ms-merge-ext-test-")));
50
+ const externalState = realpathSync(mkdtempSync(join(realTmp, "wt-ms-merge-ext-state-")));
51
+
52
+ run("git init", repo);
53
+ run("git config user.email test@test.com", repo);
54
+ run("git config user.name Test", repo);
55
+
56
+ mkdirSync(join(externalState, "worktrees"), { recursive: true });
57
+ symlinkSync(externalState, join(repo, ".gsd"));
58
+
59
+ writeFileSync(join(repo, "README.md"), "# test\n");
60
+ writeFileSync(join(externalState, "STATE.md"), "# State\n");
61
+ run("git add .", repo);
62
+ run("git commit -m init", repo);
63
+ run("git branch -M main", repo);
64
+
65
+ return { repo, externalState };
66
+ }
67
+
47
68
  /** Minimal roadmap content for mergeMilestoneToMain. */
48
69
  function makeRoadmap(milestoneId: string, title: string, slices: Array<{ id: string; title: string }>): string {
49
70
  const sliceLines = slices.map(s => `- [x] **${s.id}: ${s.title}**`).join("\n");
@@ -87,6 +108,12 @@ describe("auto-worktree-milestone-merge", { timeout: 300_000 }, () => {
87
108
  return d;
88
109
  }
89
110
 
111
+ function freshRepoWithExternalGsd(): { repo: string; externalState: string } {
112
+ const { repo, externalState } = createTempRepoWithExternalGsd();
113
+ tempDirs.push(repo, externalState);
114
+ return { repo, externalState };
115
+ }
116
+
90
117
  afterEach(() => {
91
118
  process.chdir(savedCwd);
92
119
  for (const d of tempDirs) {
@@ -638,6 +665,44 @@ describe("auto-worktree-milestone-merge", { timeout: 300_000 }, () => {
638
665
  "#1906: codeFilesChanged must be false when only .gsd/ files were merged");
639
666
  });
640
667
 
668
+ test("#2156: mergeMilestoneToMain removes external-state worktrees using the milestone branch name", () => {
669
+ const { repo, externalState } = freshRepoWithExternalGsd();
670
+ const wtPath = createAutoWorktree(repo, "M215");
671
+
672
+ addSliceToMilestone(repo, wtPath, "M215", "S01", "External cleanup", [
673
+ { file: "external-cleanup.ts", content: "export const externalCleanup = true;\n", message: "add external cleanup" },
674
+ ]);
675
+
676
+ const realWtPath = realpathSync(wtPath);
677
+ assert.ok(
678
+ realWtPath.startsWith(externalState),
679
+ `worktree should be registered under external .gsd state, got ${realWtPath}`,
680
+ );
681
+
682
+ // Recreate the exact divergence from #1852: local .gsd/ is replaced with a
683
+ // stale real directory, so worktreePath() no longer matches git's record.
684
+ unlinkSync(join(repo, ".gsd"));
685
+ mkdirSync(join(repo, ".gsd", "worktrees", "M215"), { recursive: true });
686
+ writeFileSync(join(repo, ".gsd", "STATE.md"), "# Local stale state\n");
687
+ writeFileSync(join(repo, ".gsd", "worktrees", "M215", "stale.txt"), "stale local artifact\n");
688
+
689
+ const roadmap = makeRoadmap("M215", "External cleanup", [
690
+ { id: "S01", title: "External cleanup" },
691
+ ]);
692
+
693
+ mergeMilestoneToMain(repo, "M215", roadmap);
694
+
695
+ assert.ok(
696
+ !run("git worktree list", repo).includes("M215"),
697
+ "merged milestone worktree should be removed from git worktree list",
698
+ );
699
+ assert.ok(!existsSync(realWtPath), "real external worktree directory should be removed");
700
+ assert.ok(
701
+ !run("git branch", repo).includes("milestone/M215"),
702
+ "milestone branch should be deleted after merge cleanup",
703
+ );
704
+ });
705
+
641
706
  test("#2912: MERGE_HEAD cleaned up after squash-merge conflict", () => {
642
707
  const repo = freshRepo();
643
708
  const wtPath = createAutoWorktree(repo, "M291");
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Tests for model config isolation between concurrent instances (#650, #1065)
3
- * and GSD preferences override of settings.json defaults (#3517).
3
+ * and session-scoped model precedence behavior.
4
4
  */
5
5
 
6
6
  import { describe, it, beforeEach, afterEach } from "node:test";
@@ -157,75 +157,60 @@ describe("session model recovery on error (#1065)", () => {
157
157
  });
158
158
  });
159
159
 
160
- // ─── GSD Preferences override settings.json (#3517) ─────────────────────────
160
+ // ─── Manual session model override precedence ───────────────────────────────
161
161
 
162
- describe("GSD preferences override settings.json for session model (#3517)", () => {
163
- it("preferredModel takes priority over ctx.model when both are available", () => {
164
- // Simulates auto-start.ts logic: preferredModel ?? ctx.model snapshot
165
- const preferredModel = { provider: "openai-codex", id: "gpt-5.4" };
166
- const ctxModel = { provider: "claude-code", id: "claude-sonnet-4-6" };
162
+ describe("manual session model override precedence", () => {
163
+ it("manual session override takes priority over preferences and ctx.model", () => {
164
+ const manualSessionOverride = { provider: "openai-codex", id: "gpt-5.4" };
165
+ const preferredModel = { provider: "anthropic", id: "claude-sonnet-4-6" };
166
+ const ctxModel = { provider: "claude-code", id: "claude-opus-4-6" };
167
167
 
168
- const startModelSnapshot = preferredModel
168
+ const startModelSnapshot = manualSessionOverride
169
+ ?? preferredModel
169
170
  ?? { provider: ctxModel.provider, id: ctxModel.id };
170
171
 
171
- assert.equal(startModelSnapshot.provider, "openai-codex",
172
- "preferredModel provider should win over ctx.model");
173
- assert.equal(startModelSnapshot.id, "gpt-5.4",
174
- "preferredModel id should win over ctx.model");
172
+ assert.equal(startModelSnapshot.provider, "openai-codex");
173
+ assert.equal(startModelSnapshot.id, "gpt-5.4");
174
+ });
175
+
176
+ it("falls back to preferences when no manual override is active", () => {
177
+ const manualSessionOverride: { provider: string; id: string } | undefined = undefined;
178
+ const preferredModel = { provider: "anthropic", id: "claude-sonnet-4-6" };
179
+ const ctxModel = { provider: "claude-code", id: "claude-opus-4-6" };
180
+
181
+ const startModelSnapshot = manualSessionOverride
182
+ ?? preferredModel
183
+ ?? { provider: ctxModel.provider, id: ctxModel.id };
184
+
185
+ assert.equal(startModelSnapshot.provider, "anthropic");
186
+ assert.equal(startModelSnapshot.id, "claude-sonnet-4-6");
175
187
  });
176
188
 
177
- it("falls back to ctx.model when no GSD preferences are configured", () => {
189
+ it("falls back to ctx.model when no manual override or preferences are configured", () => {
190
+ const manualSessionOverride: { provider: string; id: string } | undefined = undefined;
178
191
  const preferredModel: { provider: string; id: string } | undefined = undefined;
179
- const ctxModel = { provider: "claude-code", id: "claude-sonnet-4-6" };
192
+ const ctxModel = { provider: "claude-code", id: "claude-opus-4-6" };
180
193
 
181
- const startModelSnapshot = preferredModel
194
+ const startModelSnapshot = manualSessionOverride
195
+ ?? preferredModel
182
196
  ?? { provider: ctxModel.provider, id: ctxModel.id };
183
197
 
184
- assert.equal(startModelSnapshot.provider, "claude-code",
185
- "should fall back to ctx.model provider when no preferences");
186
- assert.equal(startModelSnapshot.id, "claude-sonnet-4-6",
187
- "should fall back to ctx.model id when no preferences");
198
+ assert.equal(startModelSnapshot.provider, "claude-code");
199
+ assert.equal(startModelSnapshot.id, "claude-opus-4-6");
188
200
  });
189
201
 
190
- it("handles null ctx.model with no preferences gracefully", () => {
202
+ it("handles null ctx.model with no override or preferences gracefully", () => {
203
+ const manualSessionOverride: { provider: string; id: string } | undefined = undefined;
191
204
  const preferredModel: { provider: string; id: string } | undefined = undefined;
192
205
  // Use a function to prevent TS from narrowing to `never` in the ternary
193
206
  function getCtxModel(): { provider: string; id: string } | null { return null; }
194
207
  const ctxModel = getCtxModel();
195
208
 
196
- const startModelSnapshot = preferredModel
209
+ const startModelSnapshot = manualSessionOverride
210
+ ?? preferredModel
197
211
  ?? (ctxModel ? { provider: ctxModel.provider, id: ctxModel.id } : null);
198
212
 
199
213
  assert.equal(startModelSnapshot, null,
200
- "should be null when neither preferences nor ctx.model exist");
201
- });
202
-
203
- it("bare model ID uses session provider when available", () => {
204
- // Simulates: PREFERENCES.md has "gpt-5.4" (no provider), session is openai-codex
205
- const preferredModel = { provider: "openai-codex", id: "gpt-5.4" }; // from resolveDefaultSessionModel("openai-codex")
206
- const ctxModel = { provider: "openai-codex", id: "claude-sonnet-4-6" };
207
-
208
- const startModelSnapshot = preferredModel
209
- ?? { provider: ctxModel.provider, id: ctxModel.id };
210
-
211
- assert.equal(startModelSnapshot.provider, "openai-codex");
212
- assert.equal(startModelSnapshot.id, "gpt-5.4",
213
- "bare model ID from preferences should still override ctx.model");
214
- });
215
-
216
- it("stale settings.json does not leak when preferences are set", () => {
217
- // Scenario: settings.json has claude-code, PREFERENCES.md has openai-codex
218
- const settingsJsonDefault = { provider: "claude-code", id: "claude-sonnet-4-6" };
219
- const preferencesModel = { provider: "openai-codex", id: "gpt-5.4" };
220
-
221
- // auto-start.ts captures preferredModel first, which preempts settingsJsonDefault
222
- const startModelSnapshot = preferencesModel ?? settingsJsonDefault;
223
-
224
- assert.equal(startModelSnapshot.provider, "openai-codex",
225
- "PREFERENCES.md must override stale settings.json provider");
226
- assert.equal(startModelSnapshot.id, "gpt-5.4",
227
- "PREFERENCES.md must override stale settings.json model");
228
- assert.notEqual(startModelSnapshot.provider, settingsJsonDefault.provider,
229
- "settings.json provider must NOT leak through");
214
+ "should be null when no model source is available");
230
215
  });
231
216
  });
@@ -0,0 +1,35 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ clearSessionModelOverride,
8
+ getSessionModelOverride,
9
+ setSessionModelOverride,
10
+ } from "../session-model-override.js";
11
+
12
+ const phasesSource = readFileSync(join(import.meta.dirname, "..", "auto", "phases.ts"), "utf-8");
13
+
14
+ test("setSessionModelOverride stores provider/model for the session", () => {
15
+ const sessionId = `session-override-${Date.now()}`;
16
+ setSessionModelOverride(sessionId, { provider: "openai-codex", id: "gpt-5.4" });
17
+
18
+ const override = getSessionModelOverride(sessionId);
19
+ assert.equal(override?.provider, "openai-codex");
20
+ assert.equal(override?.id, "gpt-5.4");
21
+ });
22
+
23
+ test("clearSessionModelOverride removes the session override", () => {
24
+ const sessionId = `session-clear-${Date.now()}`;
25
+ setSessionModelOverride(sessionId, { provider: "anthropic", id: "claude-sonnet-4-6" });
26
+ clearSessionModelOverride(sessionId);
27
+ assert.equal(getSessionModelOverride(sessionId), undefined);
28
+ });
29
+
30
+ test("auto dispatch threads manual session model override into selectAndApplyModel", () => {
31
+ assert.ok(
32
+ phasesSource.includes("s.manualSessionModelOverride"),
33
+ "auto/phases.ts should pass s.manualSessionModelOverride into selectAndApplyModel",
34
+ );
35
+ });