@stigmer/cli 3.12.4 → 3.12.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.
Files changed (63) hide show
  1. package/commands/agent-exec-flags.d.ts +1 -0
  2. package/commands/agent-exec-flags.d.ts.map +1 -1
  3. package/commands/agent-exec-flags.js +3 -1
  4. package/commands/agent-exec-flags.js.map +1 -1
  5. package/commands/draft.js +4 -2
  6. package/commands/draft.js.map +1 -1
  7. package/commands/run.js +4 -2
  8. package/commands/run.js.map +1 -1
  9. package/commands/seedpack.d.ts.map +1 -1
  10. package/commands/seedpack.js +27 -5
  11. package/commands/seedpack.js.map +1 -1
  12. package/local/seedpack/apply.d.ts +32 -3
  13. package/local/seedpack/apply.d.ts.map +1 -1
  14. package/local/seedpack/apply.js +59 -8
  15. package/local/seedpack/apply.js.map +1 -1
  16. package/package.json +5 -5
  17. package/resources/apply/apply.d.ts.map +1 -1
  18. package/resources/apply/apply.js +10 -1
  19. package/resources/apply/apply.js.map +1 -1
  20. package/resources/connect/connect.d.ts +7 -0
  21. package/resources/connect/connect.d.ts.map +1 -1
  22. package/resources/connect/connect.js +47 -30
  23. package/resources/connect/connect.js.map +1 -1
  24. package/resources/connect/display.d.ts.map +1 -1
  25. package/resources/connect/display.js +3 -0
  26. package/resources/connect/display.js.map +1 -1
  27. package/resources/run/agent-exec.d.ts.map +1 -1
  28. package/resources/run/agent-exec.js +1 -0
  29. package/resources/run/agent-exec.js.map +1 -1
  30. package/resources/run/create.d.ts +2 -1
  31. package/resources/run/create.d.ts.map +1 -1
  32. package/resources/run/create.js +11 -6
  33. package/resources/run/create.js.map +1 -1
  34. package/resources/run/prepare.d.ts +27 -1
  35. package/resources/run/prepare.d.ts.map +1 -1
  36. package/resources/run/prepare.js +41 -2
  37. package/resources/run/prepare.js.map +1 -1
  38. package/resources/task-configs.d.ts +15 -0
  39. package/resources/task-configs.d.ts.map +1 -0
  40. package/resources/task-configs.js +144 -0
  41. package/resources/task-configs.js.map +1 -0
  42. package/resources/validate.d.ts.map +1 -1
  43. package/resources/validate.js +8 -1
  44. package/resources/validate.js.map +1 -1
  45. package/src/commands/agent-exec-flags.ts +5 -2
  46. package/src/commands/draft.ts +4 -2
  47. package/src/commands/run.ts +4 -2
  48. package/src/commands/seedpack.ts +27 -5
  49. package/src/local/seedpack/apply.test.ts +127 -6
  50. package/src/local/seedpack/apply.ts +69 -9
  51. package/src/resources/apply/apply.ts +11 -1
  52. package/src/resources/connect/connect.ts +70 -44
  53. package/src/resources/connect/display.ts +3 -0
  54. package/src/resources/run/agent-exec.test.ts +1 -0
  55. package/src/resources/run/agent-exec.ts +1 -0
  56. package/src/resources/run/create.test.ts +48 -1
  57. package/src/resources/run/create.ts +11 -6
  58. package/src/resources/run/prepare.test.ts +94 -1
  59. package/src/resources/run/prepare.ts +66 -1
  60. package/src/resources/task-configs.test.ts +98 -0
  61. package/src/resources/task-configs.ts +159 -0
  62. package/src/resources/validate.test.ts +26 -0
  63. package/src/resources/validate.ts +10 -2
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { create, fromJson, type JsonValue, type Message } from "@bufbuild/protobuf";
10
10
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
11
+ import type { Workflow } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
11
12
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
12
13
  import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
13
14
  import { UpdateVisibilityInputSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
@@ -19,6 +20,7 @@ import { UsageError } from "../../errors/index.js";
19
20
  import { CommandResult } from "../../output/index.js";
20
21
  import { defaultRegistry, Verb } from "../../registry/index.js";
21
22
  import { loadDocuments, resolveYamlFiles } from "../documents.js";
23
+ import { decodeWorkflowTaskConfigs } from "../task-configs.js";
22
24
  import { type ApplyHandler, APPLY_HANDLERS, type ControllerFn } from "./handlers.js";
23
25
 
24
26
  export interface ApplyItem {
@@ -112,7 +114,15 @@ export async function applyItem(
112
114
  */
113
115
  export function marshalItem(item: ApplyItem): Message {
114
116
  try {
115
- return fromJson(item.handler.schema, item.document, { ignoreUnknownFields: false });
117
+ const message = fromJson(item.handler.schema, item.document, { ignoreUnknownFields: false });
118
+ // Workflow task_config blocks live inside an open Struct the top-level
119
+ // decode cannot see into; decode them per kind so a dry-run (and the
120
+ // reconciler's marshal) rejects exactly what a real apply rejects
121
+ // (stigmer/stigmer#778).
122
+ if (item.handler.kind === ApiResourceKind.workflow) {
123
+ decodeWorkflowTaskConfigs(message as Workflow);
124
+ }
125
+ return message;
116
126
  } catch (err) {
117
127
  throw new UsageError(`invalid ${item.handler.displayName} in ${item.filePath}: ${(err as Error).message}`);
118
128
  }
@@ -1,7 +1,9 @@
1
- // `connect mcp-server` orchestration. Mirrors Go's mcpserver.Connect (connect.go):
2
- // resolve the server, then either push to the backend (the Connect RPC runs
3
- // discovery server-side and persists capabilities + tool-approval policies) or,
4
- // for --dry-run, discover locally and return without persisting.
1
+ // `connect mcp-server` orchestration. Resolve the server, then either run the
2
+ // server-side connect (discovery + tool-approval classification, persisted on
3
+ // the resource) or, for --dry-run, discover locally and return without
4
+ // persisting. The server-side path uses the async lane — startConnect + poll
5
+ // (stigmer/stigmer#425) — with a blocking-RPC fallback for backends that
6
+ // predate it.
5
7
  //
6
8
  // OAuth: when a server requires OAuth, has no existing grant, and no --env was
7
9
  // supplied, the interactive browser flow (oauth.ts) shepherds the user through
@@ -11,10 +13,12 @@
11
13
 
12
14
  import { create } from "@bufbuild/protobuf";
13
15
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
16
+ import type { ConnectInput } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
14
17
  import { ConnectInputSchema, GetOAuthGrantStatusInputSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
15
18
  import type { DiscoveredCapabilities } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/status_pb";
16
19
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
17
20
  import type { Stigmer } from "@stigmer/sdk";
21
+ import { connectAndWait, ConnectStillRunningError, CONNECT_SETTLE_BOUND_MS } from "@stigmer/sdk";
18
22
  import type { BackendType } from "../../config/config.js";
19
23
  import { CliExitError, ExitCode, UsageError } from "../../errors/index.js";
20
24
  import { defaultRegistry } from "../../registry/index.js";
@@ -49,6 +53,13 @@ export interface ConnectResult {
49
53
  readonly capabilities: DiscoveredCapabilities | undefined;
50
54
  /** Set when capabilities were persisted (non-dry-run); undefined for dry-run. */
51
55
  readonly updated: McpServer | undefined;
56
+ /**
57
+ * Start-time advisory from the backend's connect pre-flight (e.g. "no
58
+ * runner appears to be polling the task queue"). Only set on the async
59
+ * lane, and only when the operation ultimately settled anyway — surfaced
60
+ * so the user learns their runner came up late.
61
+ */
62
+ readonly warning?: string;
52
63
  }
53
64
 
54
65
  /** Connect to an MCP server and discover its capabilities (push or dry-run). */
@@ -75,50 +86,65 @@ export async function connectMcpServer(client: Stigmer, opts: ConnectOptions): P
75
86
 
76
87
  await ensureOAuthSatisfied(client, server, opts);
77
88
 
78
- const push = client.mcpServer.connect(
79
- create(ConnectInputSchema, {
80
- mcpServerId: server.metadata?.id ?? "",
81
- org: opts.org,
82
- runtimeEnv: buildRuntimeEnv(server, opts.envOverrides),
83
- }),
84
- );
85
- const updated = opts.pushTimeoutMs === undefined
86
- ? await push
87
- : await boundedPush(push, opts.pushTimeoutMs, server);
88
- return { server, capabilities: updated.status?.discoveredCapabilities, updated };
89
+ const input = create(ConnectInputSchema, {
90
+ mcpServerId: server.metadata?.id ?? "",
91
+ org: opts.org,
92
+ runtimeEnv: buildRuntimeEnv(server, opts.envOverrides),
93
+ });
94
+ return serverSideConnect(client, server, input, opts);
89
95
  }
90
96
 
91
- // Soft timeout on the server-side connect (the client/client.ts idiom): races
92
- // the RPC against a timer without cancelling it — the backend's connect
93
- // workflow keeps running and persists its result on its own, so the message
94
- // says exactly that instead of implying the connect failed.
95
- async function boundedPush<T>(push: Promise<T>, timeoutMs: number, server: McpServer): Promise<T> {
97
+ // Run the server-side connect through the SDK's shared async-lane protocol
98
+ // (connectAndWait, stigmer/stigmer#425): startConnect + poll, with the
99
+ // blocking-RPC fallback for backends that predate the lane. The CLI's job is
100
+ // only to translate its --timeout semantics and turn a still-running stop
101
+ // into its own actionable guidance.
102
+ async function serverSideConnect(
103
+ client: Stigmer,
104
+ server: McpServer,
105
+ input: ConnectInput,
106
+ opts: ConnectOptions,
107
+ ): Promise<ConnectResult> {
96
108
  const slug = server.metadata?.slug ?? server.metadata?.id ?? "the server";
97
- let timer: NodeJS.Timeout | undefined;
98
- const timeout = new Promise<never>((_, reject) => {
99
- timer = setTimeout(
100
- () =>
101
- reject(
102
- new CliExitError(
103
- `Stopped waiting for the connect of MCP server '${slug}' after ${timeoutMs / 1000}s (--timeout)`,
104
- ExitCode.Connection,
105
- [
106
- "The server-side connect is still running and will persist its result if it succeeds.",
107
- `Check the outcome with: stigmer get mcp-server ${slug}`,
108
- "Re-run without --timeout to wait for completion.",
109
- ],
110
- ),
111
- ),
112
- timeoutMs,
113
- );
114
- });
115
- // The losing push may settle after the CLI has started exiting; swallow its
116
- // late rejection so it cannot surface as an unhandled-rejection crash.
117
- void push.catch(() => {});
109
+ let warning = "";
118
110
  try {
119
- return await Promise.race([push, timeout]);
120
- } finally {
121
- if (timer !== undefined) clearTimeout(timer);
111
+ const updated = await connectAndWait(client.mcpServer, input, {
112
+ deadlineMs: opts.pushTimeoutMs,
113
+ onStarted: (started) => {
114
+ warning = started.status?.connectStatus?.warning ?? "";
115
+ },
116
+ });
117
+ return {
118
+ server,
119
+ capabilities: updated.status?.discoveredCapabilities,
120
+ updated,
121
+ warning: warning !== "" ? warning : undefined,
122
+ };
123
+ } catch (err) {
124
+ if (!(err instanceof ConnectStillRunningError)) throw err;
125
+ if (opts.pushTimeoutMs !== undefined) {
126
+ // The user's explicit --timeout fired: historical soft-bound semantics.
127
+ throw new CliExitError(
128
+ `Stopped waiting for the connect of MCP server '${slug}' after ${opts.pushTimeoutMs / 1000}s (--timeout)`,
129
+ ExitCode.Connection,
130
+ [
131
+ "The server-side connect is still running and will persist its result if it succeeds.",
132
+ `Check the outcome with: stigmer get mcp-server ${slug}`,
133
+ "Re-run without --timeout to wait for completion.",
134
+ ],
135
+ );
136
+ }
137
+ // The SDK's settle bound fired: past the backend's own ceiling, only a
138
+ // connect_status orphaned by a backend restart can still read CONNECTING.
139
+ throw new CliExitError(
140
+ `The connect of MCP server '${slug}' did not settle within ${CONNECT_SETTLE_BOUND_MS / 60_000} minutes`,
141
+ ExitCode.Connection,
142
+ [
143
+ "This usually means the backend restarted mid-operation.",
144
+ `Check the current state with: stigmer get mcp-server ${slug}`,
145
+ "Re-run the connect to start a fresh operation.",
146
+ ],
147
+ );
122
148
  }
123
149
  }
124
150
 
@@ -26,6 +26,9 @@ export function renderConnectResult(result: ConnectResult, sink: ConnectSink, co
26
26
  sink(result.updated !== undefined
27
27
  ? style.green("✓ Connected — capabilities and tool approvals saved")
28
28
  : style.yellow("⚠ Dry run — results not saved to backend"));
29
+ if (result.warning !== undefined) {
30
+ sink(style.yellow(`⚠ ${result.warning}`));
31
+ }
29
32
  sink("");
30
33
  }
31
34
 
@@ -47,6 +47,7 @@ function makePrepared(overrides: Partial<PreparedRun> = {}): PreparedRun {
47
47
  autoApproveAll: false,
48
48
  mode: "",
49
49
  serviceTier: "",
50
+ thinking: "",
50
51
  ...overrides,
51
52
  };
52
53
  }
@@ -43,6 +43,7 @@ export async function executeResolvedAgent(input: ResolvedAgentExecInput): Promi
43
43
  model: prepared.model,
44
44
  mode: prepared.mode,
45
45
  serviceTier: prepared.serviceTier,
46
+ thinking: prepared.thinking,
46
47
  autoApproveAll: prepared.autoApproveAll,
47
48
  });
48
49
 
@@ -4,7 +4,7 @@
4
4
  // is faked to capture the exact proto sent to the RPC.
5
5
 
6
6
  import { describe, expect, it } from "vitest";
7
- import { InteractionMode, ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
7
+ import { InteractionMode, ServiceTier, ThinkingMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
8
8
  import { create } from "@bufbuild/protobuf";
9
9
  import {
10
10
  LocalPathSourceSchema,
@@ -41,6 +41,7 @@ describe("createAgentExecution", () => {
41
41
  model: "claude",
42
42
  mode: "plan",
43
43
  serviceTier: "",
44
+ thinking: "",
44
45
  autoApproveAll: true,
45
46
  });
46
47
 
@@ -69,6 +70,7 @@ describe("createAgentExecution", () => {
69
70
  model: "",
70
71
  mode: "",
71
72
  serviceTier: "",
73
+ thinking: "",
72
74
  autoApproveAll: false,
73
75
  });
74
76
  expect(exec.spec?.message).toBe("hi");
@@ -88,6 +90,7 @@ describe("createAgentExecution", () => {
88
90
  model: "composer-2.5",
89
91
  mode: "",
90
92
  serviceTier: "fast",
93
+ thinking: "",
91
94
  autoApproveAll: false,
92
95
  });
93
96
  expect(exec.spec?.executionConfig?.serviceTier).toBe(ServiceTier.FAST);
@@ -108,11 +111,52 @@ describe("createAgentExecution", () => {
108
111
  model: "",
109
112
  mode: "",
110
113
  serviceTier: "standard",
114
+ thinking: "",
111
115
  autoApproveAll: false,
112
116
  });
113
117
  expect(exec.spec?.executionConfig?.serviceTier).toBe(ServiceTier.STANDARD);
114
118
  });
115
119
 
120
+ it("maps --thinking enabled to the enum (#772)", async () => {
121
+ const { fn } = fakeController();
122
+ const exec = await createAgentExecution(fn, {
123
+ agentId: "agt_1",
124
+ orgId: "acme",
125
+ message: "x",
126
+ runtimeEnv: {},
127
+ attachments: [],
128
+ workspaceFileRefs: [],
129
+ workspaceEntries: [],
130
+ model: "claude-haiku-4-5",
131
+ mode: "",
132
+ serviceTier: "",
133
+ thinking: "enabled",
134
+ autoApproveAll: false,
135
+ });
136
+ expect(exec.spec?.executionConfig?.thinkingMode).toBe(ThinkingMode.ENABLED);
137
+ });
138
+
139
+ it("maps an explicit --thinking disabled to DISABLED, not UNSPECIFIED", async () => {
140
+ // The tier's #772 twin: unspecified-vs-explicit-disabled is the same
141
+ // load-bearing ledger distinction.
142
+ const { fn } = fakeController();
143
+ const exec = await createAgentExecution(fn, {
144
+ agentId: "agt_1",
145
+ orgId: "acme",
146
+ message: "x",
147
+ runtimeEnv: {},
148
+ attachments: [],
149
+ workspaceFileRefs: [],
150
+ workspaceEntries: [],
151
+ model: "",
152
+ mode: "",
153
+ serviceTier: "",
154
+ thinking: "disabled",
155
+ autoApproveAll: false,
156
+ });
157
+ expect(exec.spec?.executionConfig?.thinkingMode).toBe(ThinkingMode.DISABLED);
158
+ });
159
+
116
160
  it("leaves InteractionMode unspecified for agent mode", async () => {
117
161
  const { fn } = fakeController();
118
162
  const exec = await createAgentExecution(fn, {
@@ -126,6 +170,7 @@ describe("createAgentExecution", () => {
126
170
  model: "m",
127
171
  mode: "agent",
128
172
  serviceTier: "",
173
+ thinking: "",
129
174
  autoApproveAll: false,
130
175
  });
131
176
  expect(exec.spec?.sessionId).toBe("ses_1");
@@ -152,6 +197,7 @@ describe("createAgentExecution", () => {
152
197
  model: "",
153
198
  mode: "",
154
199
  serviceTier: "",
200
+ thinking: "",
155
201
  autoApproveAll: false,
156
202
  });
157
203
 
@@ -175,6 +221,7 @@ describe("createAgentExecution", () => {
175
221
  model: "",
176
222
  mode: "",
177
223
  serviceTier: "",
224
+ thinking: "",
178
225
  autoApproveAll: false,
179
226
  });
180
227
  expect(exec.spec?.sessionSpec).toBeUndefined();
@@ -10,7 +10,7 @@ import type { Client } from "@connectrpc/connect";
10
10
  import { create, type DescService } from "@bufbuild/protobuf";
11
11
  import { type AgentExecution, AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
12
12
  import { AgentExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/command_pb";
13
- import { InteractionMode, ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
13
+ import { InteractionMode, ServiceTier, ThinkingMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
14
14
  import type { Attachment } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
15
15
  import {
16
16
  AgentExecutionSpecSchema,
@@ -29,7 +29,7 @@ import { WorkflowExecutionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/
29
29
  import { ExecutionValueSchema } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
30
30
  import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
31
31
  import type { RuntimeEnv } from "./env.js";
32
- import type { RunMode, ServiceTierFlag } from "./prepare.js";
32
+ import type { RunMode, ServiceTierFlag, ThinkingFlag } from "./prepare.js";
33
33
 
34
34
  const API_VERSION = "agentic.stigmer.ai/v1";
35
35
 
@@ -58,6 +58,7 @@ export interface CreateAgentExecutionInput {
58
58
  readonly model: string;
59
59
  readonly mode: RunMode;
60
60
  readonly serviceTier: ServiceTierFlag;
61
+ readonly thinking: ThinkingFlag;
61
62
  readonly autoApproveAll: boolean;
62
63
  }
63
64
 
@@ -84,7 +85,7 @@ export async function createAgentExecution(
84
85
  input.workspaceEntries.length > 0
85
86
  ? create(SessionSpecSchema, { workspaceEntries: [...input.workspaceEntries] })
86
87
  : undefined,
87
- executionConfig: buildExecutionConfig(input.model, input.mode, input.serviceTier),
88
+ executionConfig: buildExecutionConfig(input.model, input.mode, input.serviceTier, input.thinking),
88
89
  }),
89
90
  });
90
91
  return controller(AgentExecutionCommandController).create(execution);
@@ -123,19 +124,23 @@ export async function createWorkflowExecution(
123
124
  // Build ExecutionConfig, or undefined when no flag is set so the backend
124
125
  // applies its defaults. Mirrors Go's buildExecutionConfig (only "plan" maps to a
125
126
  // non-default InteractionMode; "agent"/"" leave it unspecified). An explicit
126
- // --service-tier value maps to the enum even for "standard": unspecified vs
127
- // explicit-standard is a load-bearing ledger distinction (#357).
127
+ // --service-tier or --thinking value maps to the enum even for the base
128
+ // choice ("standard"/"disabled"): unspecified vs explicit is a load-bearing
129
+ // ledger distinction (#357/#772).
128
130
  function buildExecutionConfig(
129
131
  model: string,
130
132
  mode: RunMode,
131
133
  serviceTier: ServiceTierFlag,
134
+ thinking: ThinkingFlag,
132
135
  ): ExecutionConfig | undefined {
133
- if (model === "" && mode === "" && serviceTier === "") return undefined;
136
+ if (model === "" && mode === "" && serviceTier === "" && thinking === "") return undefined;
134
137
  const cfg = create(ExecutionConfigSchema);
135
138
  if (model !== "") cfg.modelName = model;
136
139
  if (mode === "plan") cfg.interactionMode = InteractionMode.PLAN;
137
140
  if (serviceTier === "fast") cfg.serviceTier = ServiceTier.FAST;
138
141
  else if (serviceTier === "standard") cfg.serviceTier = ServiceTier.STANDARD;
142
+ if (thinking === "enabled") cfg.thinkingMode = ThinkingMode.ENABLED;
143
+ else if (thinking === "disabled") cfg.thinkingMode = ThinkingMode.DISABLED;
139
144
  return cfg;
140
145
  }
141
146
 
@@ -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 } from "./prepare.js";
8
+ import { type AgentExecFlags, parseApprovalAction, prepareAgentExec, validateMode, validateServiceTier, validateThinking } from "./prepare.js";
9
9
 
10
10
  describe("parseApprovalAction", () => {
11
11
  it("maps each accepted value (case-insensitive)", () => {
@@ -47,6 +47,18 @@ describe("validateServiceTier", () => {
47
47
  });
48
48
  });
49
49
 
50
+ describe("validateThinking", () => {
51
+ it("accepts empty, disabled, and enabled", () => {
52
+ expect(() => validateThinking("")).not.toThrow();
53
+ expect(() => validateThinking("disabled")).not.toThrow();
54
+ expect(() => validateThinking("enabled")).not.toThrow();
55
+ });
56
+
57
+ it("rejects anything else before a network round trip", () => {
58
+ expect(() => validateThinking("harder")).toThrow(/must be "disabled" or "enabled"/);
59
+ });
60
+ });
61
+
50
62
  const BASE_FLAGS: AgentExecFlags = {
51
63
  message: "hi",
52
64
  attach: [],
@@ -64,12 +76,34 @@ const BASE_FLAGS: AgentExecFlags = {
64
76
  autoApprove: false,
65
77
  mode: "",
66
78
  serviceTier: "",
79
+ thinking: "",
67
80
  };
68
81
 
69
82
  // prepareAgentExec only touches client.agentExecution for attachment uploads;
70
83
  // with no attachments a bare stub suffices.
71
84
  const STUB_CLIENT = { agentExecution: {} } as unknown as Stigmer;
72
85
 
86
+ /** Stub whose whoAmI resolves an account carrying the given preference. */
87
+ function clientWithPreference(defaultNativeModel: string): Stigmer {
88
+ return {
89
+ agentExecution: {},
90
+ identityAccount: {
91
+ whoAmI: () =>
92
+ Promise.resolve({ spec: { preferences: { defaultNativeModel } } }),
93
+ },
94
+ } as unknown as Stigmer;
95
+ }
96
+
97
+ /** Stub whose whoAmI rejects (auth failure, backend without the RPC, ...). */
98
+ function clientWithFailingWhoAmI(): Stigmer {
99
+ return {
100
+ agentExecution: {},
101
+ identityAccount: {
102
+ whoAmI: () => Promise.reject(new Error("unimplemented")),
103
+ },
104
+ } as unknown as Stigmer;
105
+ }
106
+
73
107
  describe("prepareAgentExec env injection", () => {
74
108
  it("injects STIGMER_ORG_ID when absent", async () => {
75
109
  const prepared = await prepareAgentExec(BASE_FLAGS, STUB_CLIENT, "acme");
@@ -103,3 +137,62 @@ describe("prepareAgentExec env injection", () => {
103
137
  expect(prepared.mode).toBe("plan");
104
138
  });
105
139
  });
140
+
141
+ describe("prepareAgentExec account-preference model fill (oss#293 Phase 1.5)", () => {
142
+ it("fills an omitted --model from the account preference on cloud", async () => {
143
+ const prepared = await prepareAgentExec(
144
+ BASE_FLAGS,
145
+ clientWithPreference("claude-sonnet-4.6"),
146
+ "acme",
147
+ undefined,
148
+ { cloudBackend: true },
149
+ );
150
+ expect(prepared.model).toBe("claude-sonnet-4.6");
151
+ });
152
+
153
+ it("an explicit --model always outranks the preference", async () => {
154
+ const prepared = await prepareAgentExec(
155
+ { ...BASE_FLAGS, model: "gpt-5.3" },
156
+ clientWithPreference("claude-sonnet-4.6"),
157
+ "acme",
158
+ undefined,
159
+ { cloudBackend: true },
160
+ );
161
+ expect(prepared.model).toBe("gpt-5.3");
162
+ });
163
+
164
+ it("never consults identity on a local backend", async () => {
165
+ // The failing stub doubles as a call detector: local mode must not even
166
+ // attempt whoAmI, so a rejecting client cannot affect the result.
167
+ const prepared = await prepareAgentExec(
168
+ BASE_FLAGS,
169
+ clientWithFailingWhoAmI(),
170
+ "acme",
171
+ undefined,
172
+ { cloudBackend: false },
173
+ );
174
+ expect(prepared.model).toBe("");
175
+ });
176
+
177
+ it("falls through silently when whoAmI fails — a missing preference never fails a run", async () => {
178
+ const prepared = await prepareAgentExec(
179
+ BASE_FLAGS,
180
+ clientWithFailingWhoAmI(),
181
+ "acme",
182
+ undefined,
183
+ { cloudBackend: true },
184
+ );
185
+ expect(prepared.model).toBe("");
186
+ });
187
+
188
+ it("stays empty when the account declares no native default", async () => {
189
+ const prepared = await prepareAgentExec(
190
+ BASE_FLAGS,
191
+ clientWithPreference(""),
192
+ "acme",
193
+ undefined,
194
+ { cloudBackend: true },
195
+ );
196
+ expect(prepared.model).toBe("");
197
+ });
198
+ });
@@ -26,6 +26,12 @@ export type RunMode = "" | "agent" | "plan";
26
26
  */
27
27
  export type ServiceTierFlag = "" | "standard" | "fast";
28
28
 
29
+ /**
30
+ * The `--thinking` flag's value space (#772), the tier's twin: empty means
31
+ * unset — distinct from an explicit "disabled" for the same ledger reason.
32
+ */
33
+ export type ThinkingFlag = "" | "disabled" | "enabled";
34
+
29
35
  /** Raw agent-execution flags shared by `run` and `draft` (Go's agentExecFlags). */
30
36
  export interface AgentExecFlags {
31
37
  readonly message: string;
@@ -44,6 +50,7 @@ export interface AgentExecFlags {
44
50
  readonly autoApprove: boolean;
45
51
  readonly mode: RunMode;
46
52
  readonly serviceTier: ServiceTierFlag;
53
+ readonly thinking: ThinkingFlag;
47
54
  }
48
55
 
49
56
  /**
@@ -63,6 +70,19 @@ export interface PreparedRun {
63
70
  readonly autoApproveAll: boolean;
64
71
  readonly mode: RunMode;
65
72
  readonly serviceTier: ServiceTierFlag;
73
+ readonly thinking: ThinkingFlag;
74
+ }
75
+
76
+ /** Optional behavior switches for {@link prepareAgentExec}. */
77
+ export interface PrepareAgentExecOptions {
78
+ /**
79
+ * When `true` (the caller's backend is Stigmer Cloud) and `--model` is
80
+ * omitted, the model is filled from the caller's account preference
81
+ * (`IdentityAccountPreferences.default_native_model`) via `whoAmI()`.
82
+ * Local mode has no IdentityAccount, so callers pass `false` there and
83
+ * the omitted model keeps resolving to the platform default.
84
+ */
85
+ readonly cloudBackend?: boolean;
66
86
  }
67
87
 
68
88
  /**
@@ -74,10 +94,22 @@ export async function prepareAgentExec(
74
94
  client: Stigmer,
75
95
  org: string,
76
96
  progress?: ProgressSink,
97
+ options?: PrepareAgentExecOptions,
77
98
  ): Promise<PreparedRun> {
78
99
  const defaultAction = parseApprovalAction(flags.approveDefault);
79
100
  validateMode(flags.mode);
80
101
  validateServiceTier(flags.serviceTier);
102
+ validateThinking(flags.thinking);
103
+
104
+ // Layered model seed (oss#293 Phase 1.5): an explicit --model always wins;
105
+ // an omitted one fills from the account preference on cloud. `run` and
106
+ // `draft` always create a NEW session (threading lives in `resume`, which
107
+ // does not pass through here), so the fill never injects a native model
108
+ // into an existing cursor-harness session.
109
+ const model =
110
+ flags.model === "" && options?.cloudBackend === true
111
+ ? await resolveModelFromAccountPreference(client)
112
+ : flags.model;
81
113
 
82
114
  const workspaceEntries = parseWorkspaceEntries(flags.workspace, flags.branch, flags.commit);
83
115
 
@@ -107,13 +139,31 @@ export async function prepareAgentExec(
107
139
  message: flags.message,
108
140
  detach: flags.detach,
109
141
  verbose: flags.verbose,
110
- model: flags.model,
142
+ model,
111
143
  autoApproveAll: flags.autoApprove,
112
144
  mode: flags.mode,
113
145
  serviceTier: flags.serviceTier,
146
+ thinking: flags.thinking,
114
147
  };
115
148
  }
116
149
 
150
+ /**
151
+ * Best-effort read of the caller's default model for native-harness runs
152
+ * (CLI sessions are native — there is no --harness flag yet, so the cursor
153
+ * default is deliberately not consulted). Any failure — network, auth, a
154
+ * backend without IdentityAccount — resolves to "" and the run proceeds
155
+ * exactly as an unfilled --model does today: a missing preference must
156
+ * never fail a run.
157
+ */
158
+ async function resolveModelFromAccountPreference(client: Stigmer): Promise<string> {
159
+ try {
160
+ const account = await client.identityAccount.whoAmI();
161
+ return account.spec?.preferences?.defaultNativeModel ?? "";
162
+ } catch {
163
+ return "";
164
+ }
165
+ }
166
+
117
167
  /**
118
168
  * Parse the `--approve-default` flag to an ApprovalAction. Empty means "no
119
169
  * default" (UNSPECIFIED). Mirrors Go's approval.ParseAction, including the
@@ -162,3 +212,18 @@ export function validateServiceTier(tier: string): asserts tier is ServiceTierFl
162
212
  );
163
213
  }
164
214
  }
215
+
216
+ /**
217
+ * Validate the `--thinking` flag, the tier check's twin (#772). Empty means
218
+ * "platform default" (which resolves to disabled — never the provider
219
+ * account default). The server refuses enabled on models without the
220
+ * registry thinking capability; this check only catches spelling errors
221
+ * before a network round trip.
222
+ */
223
+ export function validateThinking(mode: string): asserts mode is ThinkingFlag {
224
+ if (mode !== "" && mode !== "disabled" && mode !== "enabled") {
225
+ throw new UsageError(
226
+ `invalid --thinking value "${mode}": must be "disabled" or "enabled"`,
227
+ );
228
+ }
229
+ }