speqs 0.5.0 → 0.6.0

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.
@@ -4,17 +4,130 @@
4
4
  * Primary command: `speqs simulation run` — orchestrates the full flow:
5
5
  * 1. Creates iteration (if not provided)
6
6
  * 2. Creates testers from profiles
7
- * 3. Starts simulations
7
+ * 3. Starts simulations (interactive or media, based on study modality)
8
8
  */
9
9
  import * as readline from "node:readline/promises";
10
- import { withClient, getWebUrl, terminalLink } from "../lib/command-helpers.js";
11
- import { output, outputError, formatSimulationPoll } from "../lib/output.js";
10
+ import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy } from "../lib/command-helpers.js";
11
+ import { resolveId } from "../lib/alias-store.js";
12
+ import { output, formatSimulationPoll } from "../lib/output.js";
12
13
  function parseMaxInteractions(value) {
13
14
  const n = parseInt(value, 10);
14
15
  if (isNaN(n) || n < 1)
15
16
  throw new Error(`Invalid --max-interactions value: ${value}`);
16
17
  return n;
17
18
  }
19
+ import { MEDIA_MODALITIES } from "../lib/types.js";
20
+ import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
21
+ function isMediaModality(modality) {
22
+ return !!modality && MEDIA_MODALITIES.includes(modality);
23
+ }
24
+ /**
25
+ * Build iteration details based on study modality and CLI options.
26
+ */
27
+ function buildCopyContent(opts) {
28
+ if (!opts.copyText)
29
+ return undefined;
30
+ return {
31
+ text: opts.copyText,
32
+ ...(opts.copyHtml && { html: opts.copyHtml }),
33
+ ...(opts.socialPlatform && { social_platform: opts.socialPlatform }),
34
+ ...(opts.copyPosition && { position: opts.copyPosition }),
35
+ };
36
+ }
37
+ function buildIterationDetails(modality, opts) {
38
+ switch (modality) {
39
+ case "text":
40
+ if (!opts.contentText) {
41
+ throw new Error("Text studies require --content-text. Provide the text content to evaluate.");
42
+ }
43
+ return {
44
+ type: "text",
45
+ content_text: opts.contentText,
46
+ ...(opts.contentHtml && { content_html: opts.contentHtml }),
47
+ ...(opts.title && { title: opts.title }),
48
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
49
+ };
50
+ case "video":
51
+ case "audio": {
52
+ if (!opts.contentUrl) {
53
+ throw new Error(`${modality} studies require --content-url. Provide the URL to the ${modality} file.`);
54
+ }
55
+ const copy = buildCopyContent(opts);
56
+ return {
57
+ type: modality,
58
+ content_url: opts.contentUrl,
59
+ ...(opts.title && { title: opts.title }),
60
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
61
+ ...(copy && { copy_content: copy }),
62
+ };
63
+ }
64
+ case "image": {
65
+ if (!opts.imageUrls) {
66
+ throw new Error("Image studies require --image-urls. Provide comma-separated image URLs.");
67
+ }
68
+ const copy = buildCopyContent(opts);
69
+ return {
70
+ type: "image",
71
+ image_urls: opts.imageUrls.split(",").map((s) => s.trim()).filter(Boolean),
72
+ ...(opts.title && { title: opts.title }),
73
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
74
+ ...(copy && { copy_content: copy }),
75
+ };
76
+ }
77
+ case "document":
78
+ if (!opts.contentUrl) {
79
+ throw new Error("Document studies require --content-url. Provide the URL to the document.");
80
+ }
81
+ return {
82
+ type: "document",
83
+ content_url: opts.contentUrl,
84
+ ...(opts.title && { title: opts.title }),
85
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
86
+ };
87
+ default:
88
+ // Interactive
89
+ if (!opts.url) {
90
+ throw new Error("Interactive studies require --url. Provide the URL to test.");
91
+ }
92
+ return {
93
+ type: "interactive",
94
+ platform: opts.platform || "browser",
95
+ url: opts.url,
96
+ screen_format: opts.screenFormat || "desktop",
97
+ ...(opts.locale && { locale: opts.locale }),
98
+ };
99
+ }
100
+ }
101
+ /**
102
+ * Copy relevant fields from a previous iteration's details for reuse.
103
+ */
104
+ function copyDetailsFromPrevious(modality, details) {
105
+ if (isMediaModality(modality)) {
106
+ const copy = details.copy_content;
107
+ return {
108
+ ...(typeof details.content_text === "string" && { contentText: details.content_text }),
109
+ ...(typeof details.content_html === "string" && { contentHtml: details.content_html }),
110
+ ...(typeof details.content_url === "string" && { contentUrl: details.content_url }),
111
+ ...(Array.isArray(details.image_urls) && { imageUrls: details.image_urls.join(",") }),
112
+ ...(typeof details.title === "string" && { title: details.title }),
113
+ ...(typeof details.mime_type === "string" && { mimeType: details.mime_type }),
114
+ ...(copy && typeof copy.text === "string" && { copyText: copy.text }),
115
+ ...(copy && typeof copy.html === "string" && { copyHtml: copy.html }),
116
+ ...(copy && typeof copy.social_platform === "string" && { socialPlatform: copy.social_platform }),
117
+ ...(copy && typeof copy.position === "string" && { copyPosition: copy.position }),
118
+ };
119
+ }
120
+ const screenFormat = typeof details.screen_format === "string"
121
+ ? details.screen_format
122
+ : typeof details.screenFormat === "string"
123
+ ? details.screenFormat
124
+ : undefined;
125
+ return {
126
+ ...(typeof details.platform === "string" && { platform: details.platform }),
127
+ ...(typeof details.url === "string" && { url: details.url }),
128
+ ...(screenFormat && { screenFormat }),
129
+ };
130
+ }
18
131
  export function registerSimulationCommands(program) {
19
132
  const sim = program
20
133
  .command("simulation")
@@ -24,96 +137,225 @@ export function registerSimulationCommands(program) {
24
137
  sim
25
138
  .command("run")
26
139
  .description("Run simulations (creates iteration + testers + starts simulations)")
27
- .requiredOption("--workspace <id>", "Workspace ID")
28
- .requiredOption("--study <id>", "Study ID")
140
+ .option("--workspace <id>", "Workspace ID")
141
+ .option("--study <id>", "Study ID")
29
142
  .option("--profiles <ids>", "Comma-separated tester profile IDs (auto-selected from last iteration if omitted)")
30
143
  .option("--iteration <id>", "Use existing iteration (skip creation)")
31
144
  .option("--iteration-name <name>", "Name for new iteration (forces creating a new iteration)")
32
- .option("--platform <platform>", "Platform (browser, android, figma, code)")
33
- .option("--url <url>", "URL to test")
34
- .option("--screen-format <format>", "Screen format (mobile_portrait, desktop)")
145
+ // Interactive options
146
+ .option("--platform <platform>", "Platform (browser, android, figma, code) — interactive only")
147
+ .option("--url <url>", "URL to test — interactive only")
148
+ .option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only")
149
+ // Media options
150
+ .option("--content-text <text>", "Text content to evaluate, or @filepath to read from file — text modality")
151
+ .option("--content-html <html>", "HTML version of the text, or @filepath to read from file — text modality")
152
+ .option("--content-url <url>", "URL or local file path to media file — video, audio, document modalities")
153
+ .option("--image-urls <urls>", "Comma-separated image URLs or local file paths — image modality")
154
+ .option("--title <title>", "Content title — media modalities")
155
+ .option("--mime-type <type>", "MIME type (e.g. video/mp4) — media modalities")
156
+ // Copy/caption options (ads & social posts)
157
+ .option("--copy-text <text>", "Ad copy or social post caption (or @filepath) — ads & social posts")
158
+ .option("--copy-html <html>", "HTML version of copy text (or @filepath)")
159
+ .option("--social-platform <platform>", "Social platform (instagram, tiktok, facebook, linkedin, x)")
160
+ .option("--copy-position <pos>", "Copy position relative to media (before, after)", "after")
161
+ .option("--config <id>", "Simulation config ID (required for media, auto-resolved for interactive)")
162
+ // Shared options
35
163
  .option("--max-interactions <n>", "Max interactions per tester")
36
164
  .option("--language <lang>", "Language code (e.g. en, sv)")
37
165
  .option("--locale <locale>", "Locale code (e.g. en-US)")
38
166
  .option("-y, --yes", "Skip confirmation prompt")
39
167
  .addHelpText("after", `
168
+ Note: --workspace and --study are optional if you have set active context
169
+ via \`speqs workspace use <alias>\` and \`speqs study use <alias>\`.
170
+ Profiles and iteration settings are auto-reused from the latest iteration.
171
+
40
172
  Examples:
41
- # Re-run with same settings and profiles as last time:
42
- $ speqs sim run --workspace W --study S
173
+ # Re-run with same settings (after setting context):
174
+ $ speqs sim run -y
175
+
176
+ # Interactive — first run:
177
+ $ speqs sim run --workspace w-6ec --study s-b2c --profiles tp-795,tp-af2 --url https://example.com
178
+
179
+ # Text/email (inline or from file):
180
+ $ speqs sim run --content-text "Your email content..." --config c-c3c
181
+ $ speqs sim run --content-text @./email.html --config c-c3c
43
182
 
44
- # Re-run with different profiles:
45
- $ speqs sim run --workspace W --study S --profiles p1,p2
183
+ # Video (URL or local file):
184
+ $ speqs sim run --content-url ./video.mp4 --config c-c3c
46
185
 
47
- # Force a new iteration:
48
- $ speqs sim run --workspace W --study S --iteration-name "Round 2"
186
+ # Image (local files):
187
+ $ speqs sim run --image-urls "./a.png,./b.png" --config c-c3c
49
188
 
50
- # First run (no previous iteration):
51
- $ speqs sim run --workspace W --study S --profiles p1,p2 --url https://example.com`)
189
+ # Document:
190
+ $ speqs sim run --content-url ./report.pdf --config c-c3c
191
+
192
+ # Video ad with copy text:
193
+ $ speqs sim run --content-url ./ad.mp4 --copy-text "Buy now — 50% off!" --config c-c3c
194
+
195
+ # Social post with caption:
196
+ $ speqs sim run --image-urls ./post.png --copy-text @./caption.txt --social-platform instagram --config c-c3c
197
+
198
+ # Re-run existing iteration:
199
+ $ speqs sim run --iteration i-d4e`)
52
200
  .action(async (opts, cmd) => {
53
201
  await withClient(cmd, async (client, globals) => {
54
202
  const log = (msg) => { if (!globals.quiet)
55
203
  console.error(msg); };
56
- // Step 0: Resolve defaults from latest iteration
57
- let resolvedPlatform = opts.platform;
58
- let resolvedUrl = opts.url;
59
- let resolvedScreenFormat = opts.screenFormat;
204
+ const resolvedWorkspace = resolveWorkspace(opts.workspace);
205
+ const resolvedStudy = resolveStudy(opts.study);
206
+ if (opts.iteration && opts.iterationName) {
207
+ throw new Error("Cannot use both --iteration and --iteration-name. Use --iteration to reuse an existing iteration, or --iteration-name to create a new one.");
208
+ }
209
+ // Step 0: Fetch study to determine modality and resolve defaults
210
+ const study = await client.get(`/studies/${resolvedStudy}`);
211
+ const modality = study.modality || "interactive";
212
+ const isMedia = isMediaModality(modality);
213
+ if (!study.assignments || study.assignments.length === 0) {
214
+ throw new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `speqs study generate`.");
215
+ }
216
+ // Validate conflicting options
217
+ if (isMedia && opts.url) {
218
+ throw new Error(`This study uses "${modality}" modality — --url is for interactive studies. Use --content-text, --content-url, or --image-urls instead.`);
219
+ }
220
+ if (!isMedia && (opts.contentText || opts.contentUrl || opts.imageUrls)) {
221
+ throw new Error(`This study uses "interactive" modality — --content-text, --content-url, and --image-urls are for media studies. Use --url instead.`);
222
+ }
223
+ // Resolve defaults from latest iteration
60
224
  let profileIds = opts.profiles
61
- ? opts.profiles.split(",").map((s) => s.trim()).filter(Boolean)
225
+ ? opts.profiles.split(",").map((s) => s.trim()).filter(Boolean).map(resolveId)
62
226
  : [];
63
227
  const profileNames = new Map();
64
- let iterationId = opts.iteration;
65
- if (!iterationId || profileIds.length === 0 || !resolvedPlatform || !resolvedScreenFormat) {
66
- const study = await client.get(`/studies/${opts.study}`);
67
- const iterations = study.iterations || [];
68
- const latest = iterations.length > 0 ? iterations[iterations.length - 1] : null;
69
- if (latest) {
70
- const iterLabel = latest.label || latest.name || latest.id.slice(0, 8);
71
- log(`Using iteration "${iterLabel}" as baseline`);
72
- // Reuse iteration if not creating a new one
73
- if (!iterationId && !opts.iterationName) {
74
- iterationId = latest.id;
75
- }
76
- // Fill platform/url/screen-format from iteration details
77
- const details = latest.details;
78
- if (details) {
79
- if (!resolvedPlatform)
80
- resolvedPlatform = details.platform;
81
- if (!resolvedUrl)
82
- resolvedUrl = details.url;
83
- if (!resolvedScreenFormat)
84
- resolvedScreenFormat = details.screen_format || details.screenFormat;
85
- }
86
- // Auto-select profiles from latest iteration's testers
87
- if (profileIds.length === 0 && latest.testers && latest.testers.length > 0) {
88
- const seen = new Set();
89
- for (const t of latest.testers) {
90
- const pid = t.tester_profile_id || t.tester_profile?.id;
91
- if (pid && !seen.has(pid)) {
92
- seen.add(pid);
93
- profileIds.push(pid);
94
- const name = t.tester_profile?.name;
95
- if (name)
96
- profileNames.set(pid, name);
97
- }
228
+ let iterationId = opts.iteration ? resolveId(opts.iteration) : undefined;
229
+ // Mutable copies for resolving from previous iteration
230
+ let resolvedOpts = { ...opts };
231
+ const iterations = study.iterations || [];
232
+ const latest = iterations.length > 0 ? iterations[iterations.length - 1] : null;
233
+ if (latest) {
234
+ const iterLabel = latest.label || latest.name || latest.id.slice(0, 8);
235
+ log(`Using iteration "${iterLabel}" as baseline`);
236
+ // Reuse iteration if not creating a new one
237
+ if (!iterationId && !opts.iterationName) {
238
+ iterationId = latest.id;
239
+ }
240
+ // Fill defaults from previous iteration details
241
+ const details = latest.details;
242
+ if (details) {
243
+ const prev = copyDetailsFromPrevious(modality, details);
244
+ if (!resolvedOpts.platform && prev.platform)
245
+ resolvedOpts.platform = prev.platform;
246
+ if (!resolvedOpts.url && prev.url)
247
+ resolvedOpts.url = prev.url;
248
+ if (!resolvedOpts.screenFormat && prev.screenFormat)
249
+ resolvedOpts.screenFormat = prev.screenFormat;
250
+ if (!resolvedOpts.contentText && prev.contentText)
251
+ resolvedOpts.contentText = prev.contentText;
252
+ if (!resolvedOpts.contentHtml && prev.contentHtml)
253
+ resolvedOpts.contentHtml = prev.contentHtml;
254
+ if (!resolvedOpts.contentUrl && prev.contentUrl)
255
+ resolvedOpts.contentUrl = prev.contentUrl;
256
+ if (!resolvedOpts.imageUrls && prev.imageUrls)
257
+ resolvedOpts.imageUrls = prev.imageUrls;
258
+ if (!resolvedOpts.title && prev.title)
259
+ resolvedOpts.title = prev.title;
260
+ if (!resolvedOpts.mimeType && prev.mimeType)
261
+ resolvedOpts.mimeType = prev.mimeType;
262
+ if (!resolvedOpts.copyText && prev.copyText)
263
+ resolvedOpts.copyText = prev.copyText;
264
+ if (!resolvedOpts.copyHtml && prev.copyHtml)
265
+ resolvedOpts.copyHtml = prev.copyHtml;
266
+ if (!resolvedOpts.socialPlatform && prev.socialPlatform)
267
+ resolvedOpts.socialPlatform = prev.socialPlatform;
268
+ if (!resolvedOpts.copyPosition && prev.copyPosition)
269
+ resolvedOpts.copyPosition = prev.copyPosition;
270
+ }
271
+ // Auto-select profiles from latest iteration's testers
272
+ if (profileIds.length === 0 && latest.testers && latest.testers.length > 0) {
273
+ const seen = new Set();
274
+ for (const t of latest.testers) {
275
+ const pid = t.tester_profile_id || t.tester_profile?.id;
276
+ if (pid && !seen.has(pid)) {
277
+ seen.add(pid);
278
+ profileIds.push(pid);
279
+ const name = t.tester_profile?.name;
280
+ if (name)
281
+ profileNames.set(pid, name);
98
282
  }
99
283
  }
100
284
  }
101
285
  }
102
- // Apply hardcoded fallbacks
103
- resolvedPlatform = resolvedPlatform || "browser";
104
- resolvedScreenFormat = resolvedScreenFormat || "desktop";
105
286
  if (profileIds.length === 0) {
106
- outputError(new Error("No profiles specified and no previous iteration to copy from. Use --profiles <ids>."), globals.json);
107
- process.exit(1);
287
+ throw new Error("No profiles specified and no previous iteration to copy from. Use --profiles <ids>.");
288
+ }
289
+ // Resolve local file paths → upload to storage and get URLs
290
+ if (isMedia) {
291
+ if (resolvedOpts.contentText) {
292
+ resolvedOpts.contentText = resolveTextContent(resolvedOpts.contentText);
293
+ }
294
+ if (resolvedOpts.contentHtml) {
295
+ resolvedOpts.contentHtml = resolveTextContent(resolvedOpts.contentHtml);
296
+ }
297
+ if (resolvedOpts.copyText) {
298
+ resolvedOpts.copyText = resolveTextContent(resolvedOpts.copyText);
299
+ }
300
+ if (resolvedOpts.copyHtml) {
301
+ resolvedOpts.copyHtml = resolveTextContent(resolvedOpts.copyHtml);
302
+ }
303
+ if (resolvedOpts.contentUrl) {
304
+ resolvedOpts.contentUrl = await resolveContentUrl(client, resolvedStudy, resolvedOpts.contentUrl, { mimeTypeOverride: resolvedOpts.mimeType, quiet: globals.quiet });
305
+ }
306
+ if (resolvedOpts.imageUrls) {
307
+ const urls = await resolveContentUrls(client, resolvedStudy, resolvedOpts.imageUrls, { mimeTypeOverride: resolvedOpts.mimeType, quiet: globals.quiet });
308
+ resolvedOpts.imageUrls = urls.join(",");
309
+ }
310
+ }
311
+ // Resolve config_id for media simulations
312
+ const resolvedConfigOverride = opts.config ? resolveId(opts.config) : undefined;
313
+ const profileConfigMap = new Map();
314
+ if (isMedia && !resolvedConfigOverride) {
315
+ // Resolve config from each profile's simulation_config_id
316
+ for (const pid of profileIds) {
317
+ const profile = await client.get(`/tester-profiles/${pid}`);
318
+ if (profile.simulation_config_id) {
319
+ profileConfigMap.set(pid, profile.simulation_config_id);
320
+ }
321
+ else {
322
+ throw new Error(`Profile ${profileNames.get(pid) || pid} has no simulation config assigned.\n` +
323
+ "Use --config <id> to specify one, or assign a config to the profile.\n" +
324
+ "List configs with: speqs config list");
325
+ }
326
+ }
108
327
  }
109
- // Confirmation step — show resolved settings
328
+ // Confirmation step
110
329
  if (!globals.json && !opts.yes) {
111
330
  log("");
112
331
  log(" Simulation settings:");
113
- log(` Platform: ${resolvedPlatform}`);
114
- log(` Screen format: ${resolvedScreenFormat}`);
115
- if (resolvedUrl)
116
- log(` URL: ${resolvedUrl}`);
332
+ log(` Modality: ${modality}`);
333
+ if (study.content_type)
334
+ log(` Content type: ${study.content_type}`);
335
+ if (isMedia) {
336
+ if (resolvedOpts.title)
337
+ log(` Title: ${resolvedOpts.title}`);
338
+ if (resolvedOpts.contentText)
339
+ log(` Content: ${resolvedOpts.contentText.slice(0, 80)}${resolvedOpts.contentText.length > 80 ? "..." : ""}`);
340
+ if (resolvedOpts.contentUrl)
341
+ log(` Content URL: ${resolvedOpts.contentUrl}`);
342
+ if (resolvedOpts.imageUrls)
343
+ log(` Image URLs: ${resolvedOpts.imageUrls}`);
344
+ if (resolvedOpts.mimeType)
345
+ log(` MIME type: ${resolvedOpts.mimeType}`);
346
+ if (resolvedOpts.copyText)
347
+ log(` Copy text: ${resolvedOpts.copyText.slice(0, 80)}${resolvedOpts.copyText.length > 80 ? "..." : ""}`);
348
+ if (resolvedOpts.socialPlatform)
349
+ log(` Platform: ${resolvedOpts.socialPlatform}`);
350
+ if (resolvedConfigOverride)
351
+ log(` Config: ${resolvedConfigOverride}`);
352
+ }
353
+ else {
354
+ log(` Platform: ${resolvedOpts.platform || "browser"}`);
355
+ log(` Screen format: ${resolvedOpts.screenFormat || "desktop"}`);
356
+ if (resolvedOpts.url)
357
+ log(` URL: ${resolvedOpts.url}`);
358
+ }
117
359
  if (opts.language)
118
360
  log(` Language: ${opts.language}`);
119
361
  log(` Profiles (${profileIds.length}):`);
@@ -133,67 +375,95 @@ Examples:
133
375
  }
134
376
  // Step 1: Create or use existing iteration
135
377
  if (!iterationId) {
136
- const iterName = opts.iterationName || `CLI ${new Date().toISOString().slice(0, 16)}`;
378
+ const iterName = resolvedOpts.iterationName || `CLI ${new Date().toISOString().slice(0, 16)}`;
137
379
  const iterBody = {
138
380
  name: iterName,
139
- details: {
140
- type: "interactive",
141
- platform: resolvedPlatform,
142
- url: resolvedUrl || "",
143
- screen_format: resolvedScreenFormat,
144
- ...(opts.locale && { locale: opts.locale }),
145
- },
381
+ details: buildIterationDetails(modality, resolvedOpts),
146
382
  };
147
383
  log(`Creating iteration "${iterName}"...`);
148
- const iter = await client.post(`/studies/${opts.study}/iterations`, iterBody);
384
+ const iter = await client.post(`/studies/${resolvedStudy}/iterations`, iterBody);
149
385
  iterationId = iter.id;
150
386
  log(`Created iteration "${iterName}"`);
151
387
  }
152
- // Step 2: Create testers from profiles
153
- const testerInputs = profileIds.map((profileId) => ({
154
- tester_profile_id: profileId,
155
- tester_type: "ai",
156
- status: "draft",
157
- ...(opts.language && { language: opts.language }),
158
- platform: resolvedPlatform,
159
- }));
160
- log(`Creating ${testerInputs.length} tester${testerInputs.length > 1 ? "s" : ""}...`);
161
- const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs });
162
- const createdTesters = batchResult.testers;
163
- log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
164
- // Step 3: Start simulations (batch) — config resolved from profiles by backend
165
- const simItems = createdTesters.map((t) => ({
166
- study_id: opts.study,
167
- tester_id: t.id,
168
- ...(opts.language && { language: opts.language }),
169
- ...(opts.locale && { locale: opts.locale }),
170
- }));
171
- log(`Starting ${simItems.length} simulation${simItems.length > 1 ? "s" : ""}...`);
172
- const simResult = await client.post("/simulation/interactive/start/batch", {
173
- product_id: opts.workspace,
174
- simulations: simItems,
175
- platform: resolvedPlatform,
176
- ...(resolvedUrl && { url: resolvedUrl }),
177
- screen_format: resolvedScreenFormat,
178
- ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
179
- }, { timeout: 60_000 });
388
+ // Step 2: Create testers from profiles (or reuse from explicit iteration)
389
+ let createdTesters;
390
+ if (opts.iteration && !opts.profiles) {
391
+ // Reuse existing testers from the explicitly provided iteration
392
+ const existingIter = await client.get(`/iterations/${iterationId}`);
393
+ if (existingIter.testers && existingIter.testers.length > 0) {
394
+ createdTesters = existingIter.testers;
395
+ log(`Reusing ${createdTesters.length} existing tester${createdTesters.length > 1 ? "s" : ""} from iteration`);
396
+ }
397
+ else {
398
+ throw new Error("Iteration has no existing testers. Use --profiles to create new testers.");
399
+ }
400
+ }
401
+ else {
402
+ const testerInputs = profileIds.map((profileId) => ({
403
+ tester_profile_id: profileId,
404
+ tester_type: "ai",
405
+ status: "draft",
406
+ ...(opts.language && { language: opts.language }),
407
+ ...(!isMedia && { platform: resolvedOpts.platform || "browser" }),
408
+ }));
409
+ log(`Creating ${testerInputs.length} tester${testerInputs.length > 1 ? "s" : ""}...`);
410
+ const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs });
411
+ createdTesters = batchResult.testers;
412
+ log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
413
+ }
414
+ // Step 3: Start simulations
415
+ log(`Starting ${createdTesters.length} simulation${createdTesters.length > 1 ? "s" : ""}...`);
416
+ let simResults;
417
+ if (isMedia) {
418
+ // Media batch endpoint — resolve config per tester from override or profile
419
+ const mediaBatchItems = createdTesters.map((t, i) => ({
420
+ study_id: resolvedStudy,
421
+ tester_id: t.id,
422
+ config_id: resolvedConfigOverride || profileConfigMap.get(profileIds[i]),
423
+ ...(opts.language && { language: opts.language }),
424
+ }));
425
+ const simResult = await client.post("/simulation/media/start/batch", {
426
+ product_id: resolvedWorkspace,
427
+ simulations: mediaBatchItems,
428
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
429
+ }, { timeout: 60_000 });
430
+ simResults = simResult.results;
431
+ }
432
+ else {
433
+ // Interactive batch endpoint
434
+ const simItems = createdTesters.map((t) => ({
435
+ study_id: resolvedStudy,
436
+ tester_id: t.id,
437
+ ...(opts.config && { config_id: resolveId(opts.config) }),
438
+ ...(opts.language && { language: opts.language }),
439
+ ...(opts.locale && { locale: opts.locale }),
440
+ }));
441
+ const simResult = await client.post("/simulation/interactive/start/batch", {
442
+ product_id: resolvedWorkspace,
443
+ simulations: simItems,
444
+ platform: resolvedOpts.platform || "browser",
445
+ ...(resolvedOpts.url && { url: resolvedOpts.url }),
446
+ screen_format: resolvedOpts.screenFormat || "desktop",
447
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
448
+ }, { timeout: 60_000 });
449
+ simResults = simResult.results;
450
+ }
180
451
  if (globals.json) {
181
452
  output({
182
453
  iteration_id: iterationId,
183
454
  testers: createdTesters.map((t) => ({ id: t.id, profile_name: t.tester_profile?.name })),
184
- simulations: simResult.results,
455
+ simulations: simResults,
185
456
  }, true);
186
457
  }
187
458
  else {
188
- // Human-readable summary (to stderr not data)
189
- for (let i = 0; i < simResult.results.length; i++) {
459
+ for (let i = 0; i < simResults.length; i++) {
190
460
  const tester = createdTesters[i];
191
461
  const profileName = tester?.tester_profile?.name || "Unknown";
192
462
  log(` ${profileName.padEnd(24)} QUEUED`);
193
463
  }
194
- const url = getWebUrl(globals, `/${opts.workspace}/${opts.study}/timeline`);
464
+ const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
195
465
  log(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
196
- log(`Run \`speqs simulation poll --study ${opts.study}\` to check progress.`);
466
+ log(`Run \`speqs simulation poll --study ${resolvedStudy}\` to check progress.`);
197
467
  }
198
468
  });
199
469
  });
@@ -208,12 +478,13 @@ Examples:
208
478
  await withClient(cmd, async (client, globals) => {
209
479
  if (jobId) {
210
480
  // Single job status
211
- const data = await client.get(`/simulation/status/${jobId}`);
481
+ const data = await client.get(`/simulation/status/${resolveId(jobId)}`);
212
482
  output(data, globals.json);
213
483
  }
214
484
  else if (opts.study) {
215
- // Get study to find all testers with running/queued simulations
216
- const study = await client.get(`/studies/${opts.study}`);
485
+ const rid = resolveId(opts.study);
486
+ const study = await client.get(`/studies/${rid}`);
487
+ const isMedia = isMediaModality(study.modality);
217
488
  // Collect all testers across iterations
218
489
  const allTesters = [];
219
490
  for (const iteration of study.iterations || []) {
@@ -223,18 +494,19 @@ Examples:
223
494
  status: tester.status,
224
495
  tester_name: tester.tester_profile?.name || "Unknown",
225
496
  interaction_count: Array.isArray(tester.interactions) ? tester.interactions.length : 0,
497
+ ...(tester.error && { error: tester.error }),
498
+ ...(tester.failure_reason && { error: tester.failure_reason }),
226
499
  });
227
500
  }
228
501
  }
229
- formatSimulationPoll(allTesters, globals.json);
502
+ formatSimulationPoll(allTesters, globals.json, isMedia);
230
503
  if (!globals.json && study.product_id) {
231
- const url = getWebUrl(globals, `/${study.product_id}/${opts.study}/timeline`);
504
+ const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
232
505
  console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
233
506
  }
234
507
  }
235
508
  else {
236
- outputError(new Error("Provide a job_id argument or --study flag"), globals.json);
237
- process.exit(1);
509
+ throw new Error("Provide a job_id argument or --study flag");
238
510
  }
239
511
  });
240
512
  });
@@ -242,8 +514,8 @@ Examples:
242
514
  sim
243
515
  .command("start")
244
516
  .description("Start a single interactive simulation (low-level)")
245
- .requiredOption("--workspace <id>", "Workspace ID")
246
- .requiredOption("--study <id>", "Study ID")
517
+ .option("--workspace <id>", "Workspace ID")
518
+ .option("--study <id>", "Study ID")
247
519
  .requiredOption("--tester <id>", "Tester ID")
248
520
  .option("--config <id>", "Simulation config ID (resolved from profile if omitted)")
249
521
  .option("--platform <platform>", "Platform (browser, android, figma, code)", "browser")
@@ -255,10 +527,10 @@ Examples:
255
527
  .action(async (opts, cmd) => {
256
528
  await withClient(cmd, async (client, globals) => {
257
529
  const body = {
258
- product_id: opts.workspace,
259
- study_id: opts.study,
260
- tester_id: opts.tester,
261
- ...(opts.config && { config_id: opts.config }),
530
+ product_id: resolveWorkspace(opts.workspace),
531
+ study_id: resolveStudy(opts.study),
532
+ tester_id: resolveId(opts.tester),
533
+ ...(opts.config && { config_id: resolveId(opts.config) }),
262
534
  platform: opts.platform,
263
535
  ...(opts.url && { url: opts.url }),
264
536
  ...(opts.screenFormat && { screen_format: opts.screenFormat }),
@@ -273,19 +545,23 @@ Examples:
273
545
  sim
274
546
  .command("start-media")
275
547
  .description("Start a media simulation (low-level)")
276
- .requiredOption("--workspace <id>", "Workspace ID")
277
- .requiredOption("--study <id>", "Study ID")
548
+ .option("--workspace <id>", "Workspace ID")
549
+ .option("--study <id>", "Study ID")
278
550
  .requiredOption("--tester <id>", "Tester ID")
279
- .option("--config <id>", "Simulation config ID (resolved from profile if omitted)")
551
+ .requiredOption("--config <id>", "Simulation config ID")
280
552
  .option("--max-interactions <n>", "Max interactions")
281
553
  .option("--language <lang>", "Language code")
554
+ .addHelpText("after", `
555
+ Examples:
556
+ $ speqs sim start-media --workspace W --study S --tester T --config C
557
+ $ speqs sim start-media --workspace W --study S --tester T --config C --max-interactions 10`)
282
558
  .action(async (opts, cmd) => {
283
559
  await withClient(cmd, async (client, globals) => {
284
560
  const body = {
285
- product_id: opts.workspace,
286
- study_id: opts.study,
287
- tester_id: opts.tester,
288
- ...(opts.config && { config_id: opts.config }),
561
+ product_id: resolveWorkspace(opts.workspace),
562
+ study_id: resolveStudy(opts.study),
563
+ tester_id: resolveId(opts.tester),
564
+ config_id: resolveId(opts.config),
289
565
  ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
290
566
  ...(opts.language && { language: opts.language }),
291
567
  };
@@ -299,7 +575,7 @@ Examples:
299
575
  .argument("<job_id>", "Job ID")
300
576
  .action(async (jobId, _opts, cmd) => {
301
577
  await withClient(cmd, async (client, globals) => {
302
- const data = await client.post(`/simulation/cancel/${jobId}`);
578
+ const data = await client.post(`/simulation/cancel/${resolveId(jobId)}`);
303
579
  output(data, globals.json);
304
580
  });
305
581
  });