@stigmer/cli 3.12.8 → 3.12.9

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.
@@ -45,6 +45,7 @@ export async function executeResolvedAgent(input: ResolvedAgentExecInput): Promi
45
45
  serviceTier: prepared.serviceTier,
46
46
  thinking: prepared.thinking,
47
47
  autoApproveAll: prepared.autoApproveAll,
48
+ harness: prepared.harness,
48
49
  });
49
50
 
50
51
  // The backend owns the canonical session id: it creates the session and
@@ -56,6 +57,7 @@ export async function executeResolvedAgent(input: ResolvedAgentExecInput): Promi
56
57
  sessionId,
57
58
  model: prepared.model,
58
59
  mode: prepared.mode,
60
+ harness: prepared.harness,
59
61
  workspaces: workspaceNames(prepared.workspaceEntries),
60
62
  };
61
63
 
@@ -6,6 +6,7 @@
6
6
  import { describe, expect, it } from "vitest";
7
7
  import { InteractionMode, ServiceTier, ThinkingMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
8
8
  import { create } from "@bufbuild/protobuf";
9
+ import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
9
10
  import {
10
11
  LocalPathSourceSchema,
11
12
  WorkspaceEntrySchema,
@@ -43,6 +44,7 @@ describe("createAgentExecution", () => {
43
44
  serviceTier: "",
44
45
  thinking: "",
45
46
  autoApproveAll: true,
47
+ harness: "",
46
48
  });
47
49
 
48
50
  expect(exec.kind).toBe("AgentExecution");
@@ -72,6 +74,7 @@ describe("createAgentExecution", () => {
72
74
  serviceTier: "",
73
75
  thinking: "",
74
76
  autoApproveAll: false,
77
+ harness: "",
75
78
  });
76
79
  expect(exec.spec?.message).toBe("hi");
77
80
  expect(exec.spec?.executionConfig).toBeUndefined();
@@ -92,6 +95,7 @@ describe("createAgentExecution", () => {
92
95
  serviceTier: "fast",
93
96
  thinking: "",
94
97
  autoApproveAll: false,
98
+ harness: "",
95
99
  });
96
100
  expect(exec.spec?.executionConfig?.serviceTier).toBe(ServiceTier.FAST);
97
101
  });
@@ -113,6 +117,7 @@ describe("createAgentExecution", () => {
113
117
  serviceTier: "standard",
114
118
  thinking: "",
115
119
  autoApproveAll: false,
120
+ harness: "",
116
121
  });
117
122
  expect(exec.spec?.executionConfig?.serviceTier).toBe(ServiceTier.STANDARD);
118
123
  });
@@ -132,6 +137,7 @@ describe("createAgentExecution", () => {
132
137
  serviceTier: "",
133
138
  thinking: "enabled",
134
139
  autoApproveAll: false,
140
+ harness: "",
135
141
  });
136
142
  expect(exec.spec?.executionConfig?.thinkingMode).toBe(ThinkingMode.ENABLED);
137
143
  });
@@ -153,6 +159,7 @@ describe("createAgentExecution", () => {
153
159
  serviceTier: "",
154
160
  thinking: "disabled",
155
161
  autoApproveAll: false,
162
+ harness: "",
156
163
  });
157
164
  expect(exec.spec?.executionConfig?.thinkingMode).toBe(ThinkingMode.DISABLED);
158
165
  });
@@ -172,6 +179,7 @@ describe("createAgentExecution", () => {
172
179
  serviceTier: "",
173
180
  thinking: "",
174
181
  autoApproveAll: false,
182
+ harness: "",
175
183
  });
176
184
  expect(exec.spec?.sessionId).toBe("ses_1");
177
185
  expect(exec.spec?.executionConfig?.interactionMode).toBe(InteractionMode.UNSPECIFIED);
@@ -199,6 +207,7 @@ describe("createAgentExecution", () => {
199
207
  serviceTier: "",
200
208
  thinking: "",
201
209
  autoApproveAll: false,
210
+ harness: "",
202
211
  });
203
212
 
204
213
  expect(exec.spec?.sessionId).toBe("");
@@ -206,9 +215,16 @@ describe("createAgentExecution", () => {
206
215
  // Subject stays empty so the server defaults its sentinel and the async
207
216
  // title activity generates a real one.
208
217
  expect(exec.spec?.sessionSpec?.subject).toBe("");
218
+ // No harness opinion: the field stays UNSPECIFIED so the server default
219
+ // (native) applies — a workspace-only run is byte-identical to before
220
+ // the --harness flag existed.
221
+ expect(exec.spec?.sessionSpec?.harness).toBe(Harness.UNSPECIFIED);
209
222
  });
210
223
 
211
- it("omits session_spec when there are no workspace entries", async () => {
224
+ it("omits session_spec when there are no workspace entries and no harness (wire-shape regression pin)", async () => {
225
+ // The pre---harness wire shape for a plain run: NO embedded session_spec
226
+ // at all. If this pin fails, plain runs have started carrying a spec they
227
+ // never carried before — a silent contract change, not a feature.
212
228
  const { fn } = fakeController();
213
229
  const exec = await createAgentExecution(fn, {
214
230
  agentId: "agt_1",
@@ -223,9 +239,84 @@ describe("createAgentExecution", () => {
223
239
  serviceTier: "",
224
240
  thinking: "",
225
241
  autoApproveAll: false,
242
+ harness: "",
226
243
  });
227
244
  expect(exec.spec?.sessionSpec).toBeUndefined();
228
245
  });
246
+
247
+ it("stamps cursor harness on a session_spec created just for it (oss#293)", async () => {
248
+ const { fn } = fakeController();
249
+ const exec = await createAgentExecution(fn, {
250
+ agentId: "agt_1",
251
+ orgId: "acme",
252
+ message: "hi",
253
+ runtimeEnv: {},
254
+ attachments: [],
255
+ workspaceFileRefs: [],
256
+ workspaceEntries: [],
257
+ model: "",
258
+ mode: "",
259
+ serviceTier: "",
260
+ thinking: "",
261
+ autoApproveAll: false,
262
+ harness: "cursor",
263
+ });
264
+ // Harness alone justifies the embedded spec: the server clones it onto
265
+ // the auto-created session (the one-call bootstrap contract).
266
+ expect(exec.spec?.sessionSpec?.harness).toBe(Harness.CURSOR);
267
+ expect(exec.spec?.sessionSpec?.workspaceEntries).toEqual([]);
268
+ });
269
+
270
+ it("stamps an explicit native harness rather than leaving it unspecified", async () => {
271
+ // "native" may be a deliberate per-run escape from the account's
272
+ // default_harness preference — it must survive on the wire so it beats
273
+ // any future change to the server-side default (D3).
274
+ const { fn } = fakeController();
275
+ const exec = await createAgentExecution(fn, {
276
+ agentId: "agt_1",
277
+ orgId: "acme",
278
+ message: "hi",
279
+ runtimeEnv: {},
280
+ attachments: [],
281
+ workspaceFileRefs: [],
282
+ workspaceEntries: [],
283
+ model: "",
284
+ mode: "",
285
+ serviceTier: "",
286
+ thinking: "",
287
+ autoApproveAll: false,
288
+ harness: "native",
289
+ });
290
+ expect(exec.spec?.sessionSpec?.harness).toBe(Harness.NATIVE);
291
+ });
292
+
293
+ it("carries harness and workspace entries together on one session_spec", async () => {
294
+ const entry = create(WorkspaceEntrySchema, {
295
+ name: "repo",
296
+ source: create(WorkspaceSourceSchema, {
297
+ source: { case: "localPath", value: create(LocalPathSourceSchema, { path: "/home/user/repo" }) },
298
+ }),
299
+ });
300
+
301
+ const { fn } = fakeController();
302
+ const exec = await createAgentExecution(fn, {
303
+ agentId: "agt_1",
304
+ orgId: "acme",
305
+ message: "hi",
306
+ runtimeEnv: {},
307
+ attachments: [],
308
+ workspaceFileRefs: [],
309
+ workspaceEntries: [entry],
310
+ model: "",
311
+ mode: "",
312
+ serviceTier: "",
313
+ thinking: "",
314
+ autoApproveAll: false,
315
+ harness: "cursor",
316
+ });
317
+ expect(exec.spec?.sessionSpec?.harness).toBe(Harness.CURSOR);
318
+ expect(exec.spec?.sessionSpec?.workspaceEntries).toEqual([entry]);
319
+ });
229
320
  });
230
321
 
231
322
  describe("createWorkflowExecution", () => {
@@ -18,7 +18,8 @@ import {
18
18
  ExecutionConfigSchema,
19
19
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
20
20
  import type { ExecutionValue } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
21
- import { SessionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/spec_pb";
21
+ import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
22
+ import { type SessionSpec, SessionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/spec_pb";
22
23
  import type { WorkspaceEntry } from "@stigmer/protos/ai/stigmer/agentic/session/v1/workspace_pb";
23
24
  import {
24
25
  type WorkflowExecution,
@@ -29,7 +30,7 @@ import { WorkflowExecutionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/
29
30
  import { ExecutionValueSchema } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
30
31
  import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
31
32
  import type { RuntimeEnv } from "./env.js";
32
- import type { RunMode, ServiceTierFlag, ThinkingFlag } from "./prepare.js";
33
+ import type { HarnessFlag, RunMode, ServiceTierFlag, ThinkingFlag } from "./prepare.js";
33
34
 
34
35
  const API_VERSION = "agentic.stigmer.ai/v1";
35
36
 
@@ -60,6 +61,7 @@ export interface CreateAgentExecutionInput {
60
61
  readonly serviceTier: ServiceTierFlag;
61
62
  readonly thinking: ThinkingFlag;
62
63
  readonly autoApproveAll: boolean;
64
+ readonly harness: HarnessFlag;
63
65
  }
64
66
 
65
67
  /** Create an agent execution. Mirrors Go's createAgentExecution. */
@@ -81,10 +83,7 @@ export async function createAgentExecution(
81
83
  agentId: input.agentId ?? "",
82
84
  // Subject is left empty: the server defaults its sentinel and the async
83
85
  // title activity replaces it, same as any auto-created session.
84
- sessionSpec:
85
- input.workspaceEntries.length > 0
86
- ? create(SessionSpecSchema, { workspaceEntries: [...input.workspaceEntries] })
87
- : undefined,
86
+ sessionSpec: buildSessionSpec(input.workspaceEntries, input.harness),
88
87
  executionConfig: buildExecutionConfig(input.model, input.mode, input.serviceTier, input.thinking),
89
88
  }),
90
89
  });
@@ -121,6 +120,26 @@ export async function createWorkflowExecution(
121
120
  return controller(WorkflowExecutionCommandController).create(execution);
122
121
  }
123
122
 
123
+ // Build the embedded session spec for the one-call bootstrap
124
+ // (stigmer/stigmer#249), or undefined when there is nothing to carry — the
125
+ // pre-existing wire shape for a plain run must stay byte-identical. A resolved
126
+ // harness is stamped explicitly, including "native": the value may be a
127
+ // deliberate per-run escape from the account's default_harness preference, so
128
+ // it must survive any future change to the server-side default. Empty means
129
+ // "no opinion" and stays off the wire (server defaults to native). The server
130
+ // clones this spec onto the auto-created session and then clears it from the
131
+ // persisted execution — the Session resource stays the single source of truth.
132
+ function buildSessionSpec(
133
+ workspaceEntries: readonly WorkspaceEntry[],
134
+ harness: HarnessFlag,
135
+ ): SessionSpec | undefined {
136
+ if (workspaceEntries.length === 0 && harness === "") return undefined;
137
+ const spec = create(SessionSpecSchema, { workspaceEntries: [...workspaceEntries] });
138
+ if (harness === "cursor") spec.harness = Harness.CURSOR;
139
+ else if (harness === "native") spec.harness = Harness.NATIVE;
140
+ return spec;
141
+ }
142
+
124
143
  // Build ExecutionConfig, or undefined when no flag is set so the backend
125
144
  // applies its defaults. Mirrors Go's buildExecutionConfig (only "plan" maps to a
126
145
  // non-default InteractionMode; "agent"/"" leave it unspecified). An explicit
@@ -31,6 +31,24 @@ describe("renderSessionHeader", () => {
31
31
  expect(agent.text()).not.toContain("Mode:");
32
32
  });
33
33
 
34
+ it("surfaces the cursor harness but not native/default (oss#293 D2 visibility)", () => {
35
+ // A cursor session may have been selected by the account preference, not
36
+ // a flag — the header is the CLI's only pre-stream channel to say so.
37
+ const cursor = capture();
38
+ renderSessionHeader(cursor.out, { agentName: "", sessionId: "s", model: "", mode: "", harness: "cursor", workspaces: [] });
39
+ expect(cursor.text()).toContain("Harness:");
40
+ expect(cursor.text()).toContain("Cursor");
41
+
42
+ // Native (explicit or defaulted) stays implicit, like agent mode.
43
+ const native = capture();
44
+ renderSessionHeader(native.out, { agentName: "", sessionId: "s", model: "", mode: "", harness: "native", workspaces: [] });
45
+ expect(native.text()).not.toContain("Harness:");
46
+
47
+ const unset = capture();
48
+ renderSessionHeader(unset.out, { agentName: "", sessionId: "s", model: "", mode: "", workspaces: [] });
49
+ expect(unset.text()).not.toContain("Harness:");
50
+ });
51
+
34
52
  it("indents extra workspaces under the first", () => {
35
53
  const { out, text } = capture();
36
54
  renderSessionHeader(out, {
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { WorkspaceEntry } from "@stigmer/protos/ai/stigmer/agentic/session/v1/workspace_pb";
13
13
  import { shouldColorize, styler } from "../../output/style.js";
14
- import type { RunMode } from "./prepare.js";
14
+ import type { HarnessFlag, RunMode } from "./prepare.js";
15
15
 
16
16
  export interface SessionHeaderInfo {
17
17
  readonly agentName: string;
@@ -20,6 +20,14 @@ export interface SessionHeaderInfo {
20
20
  readonly subject?: string;
21
21
  readonly model: string;
22
22
  readonly mode: RunMode;
23
+ /**
24
+ * Resolved harness for the session. Surfaced only when it is "cursor":
25
+ * a harness switch changes the toolset, conversation-state model, and
26
+ * billing tier, and it may come from the account preference rather than
27
+ * a flag — a cursor session must never start silently (the CLI
28
+ * counterpart of the web composer's harness selector).
29
+ */
30
+ readonly harness?: HarnessFlag;
23
31
  readonly workspaces: readonly string[];
24
32
  }
25
33
 
@@ -39,6 +47,8 @@ export function renderSessionHeader(out: { write(chunk: string): unknown; isTTY?
39
47
  if (info.sessionId !== "") lines.push(row(s, "Session", info.sessionId));
40
48
  if ((info.subject ?? "") !== "") lines.push(row(s, "Subject", info.subject ?? ""));
41
49
  if (info.model !== "") lines.push(row(s, "Model", info.model));
50
+ // Only "cursor" is surfaced; native/"" is the default and stays implicit.
51
+ if (info.harness === "cursor") lines.push(row(s, "Harness", "Cursor"));
42
52
  // Only "plan" is surfaced; "agent"/"" is the default and stays implicit.
43
53
  if (info.mode === "plan") lines.push(row(s, "Mode", "Plan (read-only)"));
44
54
  if (info.workspaces.length > 0) {
@@ -5,7 +5,7 @@
5
5
  import { describe, expect, it } from "vitest";
6
6
  import { ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
7
7
  import type { Stigmer } from "@stigmer/sdk";
8
- import { type AgentExecFlags, parseApprovalAction, prepareAgentExec, validateMode, validateServiceTier, validateThinking } from "./prepare.js";
8
+ import { type AgentExecFlags, parseApprovalAction, prepareAgentExec, validateHarness, validateMode, validateServiceTier, validateThinking } from "./prepare.js";
9
9
 
10
10
  describe("parseApprovalAction", () => {
11
11
  it("maps each accepted value (case-insensitive)", () => {
@@ -59,6 +59,24 @@ describe("validateThinking", () => {
59
59
  });
60
60
  });
61
61
 
62
+ describe("validateHarness", () => {
63
+ it("accepts empty, native, and cursor", () => {
64
+ expect(() => validateHarness("")).not.toThrow();
65
+ expect(() => validateHarness("native")).not.toThrow();
66
+ expect(() => validateHarness("cursor")).not.toThrow();
67
+ });
68
+
69
+ it("rejects anything else before a network round trip", () => {
70
+ expect(() => validateHarness("turbo")).toThrow(/must be "native" or "cursor"/);
71
+ });
72
+
73
+ it("rejects UI-only harness names that have no proto mapping", () => {
74
+ // copilot/codex/etc. exist in display metadata but not on the wire —
75
+ // accepting them here would stamp UNSPECIFIED and silently run native.
76
+ expect(() => validateHarness("copilot")).toThrow(/must be "native" or "cursor"/);
77
+ });
78
+ });
79
+
62
80
  const BASE_FLAGS: AgentExecFlags = {
63
81
  message: "hi",
64
82
  attach: [],
@@ -77,23 +95,35 @@ const BASE_FLAGS: AgentExecFlags = {
77
95
  mode: "",
78
96
  serviceTier: "",
79
97
  thinking: "",
98
+ harness: "",
80
99
  };
81
100
 
82
101
  // prepareAgentExec only touches client.agentExecution for attachment uploads;
83
102
  // with no attachments a bare stub suffices.
84
103
  const STUB_CLIENT = { agentExecution: {} } as unknown as Stigmer;
85
104
 
86
- /** Stub whose whoAmI resolves an account carrying the given preference. */
87
- function clientWithPreference(defaultNativeModel: string): Stigmer {
105
+ /** The shape of IdentityAccountPreferences the stubs below expose. */
106
+ interface StubPreferences {
107
+ readonly defaultHarness?: string;
108
+ readonly defaultNativeModel?: string;
109
+ readonly defaultCursorModel?: string;
110
+ }
111
+
112
+ /** Stub whose whoAmI resolves an account carrying the given preferences. */
113
+ function clientWithPreferences(preferences: StubPreferences): Stigmer {
88
114
  return {
89
115
  agentExecution: {},
90
116
  identityAccount: {
91
- whoAmI: () =>
92
- Promise.resolve({ spec: { preferences: { defaultNativeModel } } }),
117
+ whoAmI: () => Promise.resolve({ spec: { preferences } }),
93
118
  },
94
119
  } as unknown as Stigmer;
95
120
  }
96
121
 
122
+ /** Back-compat shorthand for the original native-model-only stub. */
123
+ function clientWithPreference(defaultNativeModel: string): Stigmer {
124
+ return clientWithPreferences({ defaultNativeModel });
125
+ }
126
+
97
127
  /** Stub whose whoAmI rejects (auth failure, backend without the RPC, ...). */
98
128
  function clientWithFailingWhoAmI(): Stigmer {
99
129
  return {
@@ -104,6 +134,24 @@ function clientWithFailingWhoAmI(): Stigmer {
104
134
  } as unknown as Stigmer;
105
135
  }
106
136
 
137
+ /** Preference stub that also counts whoAmI invocations (round-trip pin). */
138
+ function countingClient(preferences: StubPreferences): { client: Stigmer; calls: () => number } {
139
+ let calls = 0;
140
+ const client = {
141
+ agentExecution: {},
142
+ identityAccount: {
143
+ whoAmI: () => {
144
+ calls += 1;
145
+ return Promise.resolve({ spec: { preferences } });
146
+ },
147
+ },
148
+ } as unknown as Stigmer;
149
+ return { client, calls: () => calls };
150
+ }
151
+
152
+ /** prepareAgentExec options as `run` passes them: cloud + harness opt-in. */
153
+ const CLOUD_RUN_OPTIONS = { cloudBackend: true, applyAccountHarnessDefault: true } as const;
154
+
107
155
  describe("prepareAgentExec env injection", () => {
108
156
  it("injects STIGMER_ORG_ID when absent", async () => {
109
157
  const prepared = await prepareAgentExec(BASE_FLAGS, STUB_CLIENT, "acme");
@@ -196,3 +244,197 @@ describe("prepareAgentExec account-preference model fill (oss#293 Phase 1.5)", (
196
244
  expect(prepared.model).toBe("");
197
245
  });
198
246
  });
247
+
248
+ describe("prepareAgentExec harness resolution (oss#293)", () => {
249
+ it("fills an omitted --harness from the account preference on cloud when the caller opts in (run)", async () => {
250
+ const prepared = await prepareAgentExec(
251
+ BASE_FLAGS,
252
+ clientWithPreferences({ defaultHarness: "cursor" }),
253
+ "acme",
254
+ undefined,
255
+ CLOUD_RUN_OPTIONS,
256
+ );
257
+ expect(prepared.harness).toBe("cursor");
258
+ });
259
+
260
+ it("an explicit --harness always outranks the account preference", async () => {
261
+ // The per-run escape hatch: a cursor-preference user can still force a
262
+ // native run without touching their settings.
263
+ const prepared = await prepareAgentExec(
264
+ { ...BASE_FLAGS, harness: "native" },
265
+ clientWithPreferences({ defaultHarness: "cursor" }),
266
+ "acme",
267
+ undefined,
268
+ CLOUD_RUN_OPTIONS,
269
+ );
270
+ expect(prepared.harness).toBe("native");
271
+ });
272
+
273
+ it("ignores the preference when the caller has not opted in (draft, D5)", async () => {
274
+ // draft runs the seedpack's system creator agents — a utility flow that
275
+ // must not be silently rerouted onto the cursor engine by a preference
276
+ // meant for the user's own sessions (owner-ratified D5).
277
+ const prepared = await prepareAgentExec(
278
+ BASE_FLAGS,
279
+ clientWithPreferences({ defaultHarness: "cursor" }),
280
+ "acme",
281
+ undefined,
282
+ { cloudBackend: true },
283
+ );
284
+ expect(prepared.harness).toBe("");
285
+ });
286
+
287
+ it("an explicit --harness still resolves for a non-opted-in caller (draft escape hatch)", async () => {
288
+ const prepared = await prepareAgentExec(
289
+ { ...BASE_FLAGS, harness: "cursor" },
290
+ STUB_CLIENT,
291
+ "acme",
292
+ undefined,
293
+ { cloudBackend: false },
294
+ );
295
+ expect(prepared.harness).toBe("cursor");
296
+ });
297
+
298
+ it("treats a malformed persisted harness as undeclared", async () => {
299
+ // A client must not trust stored data it did not write: anything outside
300
+ // the shipped allowlist resolves as if no preference existed.
301
+ const prepared = await prepareAgentExec(
302
+ BASE_FLAGS,
303
+ clientWithPreferences({ defaultHarness: "devin" }),
304
+ "acme",
305
+ undefined,
306
+ CLOUD_RUN_OPTIONS,
307
+ );
308
+ expect(prepared.harness).toBe("");
309
+ });
310
+
311
+ it("resolves empty when whoAmI fails — a missing preference never fails a run", async () => {
312
+ const prepared = await prepareAgentExec(
313
+ BASE_FLAGS,
314
+ clientWithFailingWhoAmI(),
315
+ "acme",
316
+ undefined,
317
+ CLOUD_RUN_OPTIONS,
318
+ );
319
+ expect(prepared.harness).toBe("");
320
+ expect(prepared.model).toBe("");
321
+ });
322
+
323
+ it("never consults identity on a local backend, even with the opt-in", async () => {
324
+ // The failing stub doubles as a call detector, as in the model-fill suite.
325
+ const prepared = await prepareAgentExec(
326
+ BASE_FLAGS,
327
+ clientWithFailingWhoAmI(),
328
+ "acme",
329
+ undefined,
330
+ { cloudBackend: false, applyAccountHarnessDefault: true },
331
+ );
332
+ expect(prepared.harness).toBe("");
333
+ });
334
+
335
+ it("resolves empty when nothing is declared anywhere", async () => {
336
+ const prepared = await prepareAgentExec(
337
+ BASE_FLAGS,
338
+ clientWithPreferences({}),
339
+ "acme",
340
+ undefined,
341
+ CLOUD_RUN_OPTIONS,
342
+ );
343
+ expect(prepared.harness).toBe("");
344
+ });
345
+ });
346
+
347
+ describe("prepareAgentExec harness-aware model fill (oss#293)", () => {
348
+ const BOTH_MODELS: StubPreferences = {
349
+ defaultNativeModel: "claude-sonnet-4.6",
350
+ defaultCursorModel: "composer-2.5",
351
+ };
352
+
353
+ it("fills the cursor model when the explicit harness is cursor", async () => {
354
+ const prepared = await prepareAgentExec(
355
+ { ...BASE_FLAGS, harness: "cursor" },
356
+ clientWithPreferences(BOTH_MODELS),
357
+ "acme",
358
+ undefined,
359
+ CLOUD_RUN_OPTIONS,
360
+ );
361
+ expect(prepared.model).toBe("composer-2.5");
362
+ });
363
+
364
+ it("fills the cursor model when the preference resolves the harness to cursor", async () => {
365
+ const prepared = await prepareAgentExec(
366
+ BASE_FLAGS,
367
+ clientWithPreferences({ ...BOTH_MODELS, defaultHarness: "cursor" }),
368
+ "acme",
369
+ undefined,
370
+ CLOUD_RUN_OPTIONS,
371
+ );
372
+ expect(prepared.harness).toBe("cursor");
373
+ expect(prepared.model).toBe("composer-2.5");
374
+ });
375
+
376
+ it("fills the native model when the harness resolves native or empty", async () => {
377
+ const explicit = await prepareAgentExec(
378
+ { ...BASE_FLAGS, harness: "native" },
379
+ clientWithPreferences(BOTH_MODELS),
380
+ "acme",
381
+ undefined,
382
+ CLOUD_RUN_OPTIONS,
383
+ );
384
+ expect(explicit.model).toBe("claude-sonnet-4.6");
385
+
386
+ const unset = await prepareAgentExec(
387
+ BASE_FLAGS,
388
+ clientWithPreferences(BOTH_MODELS),
389
+ "acme",
390
+ undefined,
391
+ CLOUD_RUN_OPTIONS,
392
+ );
393
+ expect(unset.model).toBe("claude-sonnet-4.6");
394
+ });
395
+
396
+ it("an explicit --model outranks the harness-matched preference", async () => {
397
+ const prepared = await prepareAgentExec(
398
+ { ...BASE_FLAGS, harness: "cursor", model: "gpt-5.3" },
399
+ clientWithPreferences(BOTH_MODELS),
400
+ "acme",
401
+ undefined,
402
+ CLOUD_RUN_OPTIONS,
403
+ );
404
+ expect(prepared.model).toBe("gpt-5.3");
405
+ });
406
+
407
+ it("draft with an explicit --harness cursor fills the cursor model despite no harness opt-in (D5)", async () => {
408
+ // D5 scopes only the silent preference fill; the model fill follows
409
+ // whatever harness the user explicitly chose.
410
+ const prepared = await prepareAgentExec(
411
+ { ...BASE_FLAGS, harness: "cursor" },
412
+ clientWithPreferences(BOTH_MODELS),
413
+ "acme",
414
+ undefined,
415
+ { cloudBackend: true },
416
+ );
417
+ expect(prepared.harness).toBe("cursor");
418
+ expect(prepared.model).toBe("composer-2.5");
419
+ });
420
+
421
+ it("serves both fills from one whoAmI round trip, and none when both flags are explicit", async () => {
422
+ const single = countingClient({ ...BOTH_MODELS, defaultHarness: "cursor" });
423
+ const filled = await prepareAgentExec(BASE_FLAGS, single.client, "acme", undefined, CLOUD_RUN_OPTIONS);
424
+ expect(filled.harness).toBe("cursor");
425
+ expect(filled.model).toBe("composer-2.5");
426
+ expect(single.calls(), "harness + model fills share one whoAmI").toBe(1);
427
+
428
+ const skipped = countingClient(BOTH_MODELS);
429
+ const explicit = await prepareAgentExec(
430
+ { ...BASE_FLAGS, harness: "native", model: "gpt-5.3" },
431
+ skipped.client,
432
+ "acme",
433
+ undefined,
434
+ CLOUD_RUN_OPTIONS,
435
+ );
436
+ expect(explicit.harness).toBe("native");
437
+ expect(explicit.model).toBe("gpt-5.3");
438
+ expect(skipped.calls(), "explicit flags leave nothing to fill — no round trip").toBe(0);
439
+ });
440
+ });