@clien-ai/mcp 0.17.0 → 0.18.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.
@@ -87,6 +87,11 @@ export function normalizeRunManifest(raw) {
87
87
  ? { notApplicablePhases: tokens(rawSummary.notApplicablePhases) }
88
88
  : {}),
89
89
  missingPhases: tokens(rawSummary.missingPhases),
90
+ // FUL-1188, same skew rule: `[]` would tell a structured host
91
+ // "this run kind has every phase", which an older deploy never said.
92
+ ...(Array.isArray(rawSummary.structurallyAbsentPhases)
93
+ ? { structurallyAbsentPhases: tokens(rawSummary.structurallyAbsentPhases) }
94
+ : {}),
90
95
  incompletePhase: safeInline(rawSummary.incompletePhase, TOKEN_MAX),
91
96
  incompleteReason: safeInline(rawSummary.incompleteReason, TOKEN_MAX),
92
97
  // Same skew rule as `notApplicablePhases`: an older deploy never
@@ -104,6 +109,21 @@ export const MANIFEST_BLOCK_HEADING = 'RUN PROVENANCE';
104
109
  function listOrNone(values) {
105
110
  return values.length > 0 ? values.join(', ') : 'none';
106
111
  }
112
+ /**
113
+ * FUL-1188 — the clause naming phases this run kind never has, or `''`.
114
+ *
115
+ * Printed only for a NON-EMPTY list. An absent list (an app deploy older than
116
+ * the field) and an empty one (a hero run) both print nothing: there is no
117
+ * "none" branch, so this renderer can never claim that no phases are absent on
118
+ * the strength of a server that did not say.
119
+ */
120
+ function structurallyAbsentClause(summary) {
121
+ const absent = summary?.structurallyAbsentPhases;
122
+ if (!absent || absent.length === 0)
123
+ return '';
124
+ const kind = summary?.runKind ? `${summary.runKind} runs` : 'this kind of run';
125
+ return `; never part of ${kind}: ${absent.join(', ')}`;
126
+ }
107
127
  /**
108
128
  * Render one manifest answer as agent-visible text. `''` for `null` — an unknown
109
129
  * answer prints NOTHING rather than a hedge an agent could mistake for a fact.
@@ -142,12 +162,13 @@ export function renderRunManifest(view, context) {
142
162
  'phase, so nothing it did is in the provenance record (phases never sealed: ' +
143
163
  `${listOrNone(summary.missingPhases ?? [])})`
144
164
  : `phases still unsealed: ${listOrNone(summary?.missingPhases ?? [])}`;
165
+ const absent = structurallyAbsentClause(summary);
145
166
  // The report clause is the CALLER's to make, never this renderer's: a
146
167
  // cancelled run reaches here with no report at all.
147
168
  const stillReadable = context.reportDelivered
148
169
  ? 'The report above is readable and its findings are unaffected, but this '
149
170
  : 'This ';
150
- return (`${head}INCOMPLETE. ${kind}${failed}. ${stillReadable}run's provenance ` +
171
+ return (`${head}INCOMPLETE. ${kind}${failed}${absent}. ${stillReadable}run's provenance ` +
151
172
  'has a gap: what it ran cannot be fully reconstructed from the record, ' +
152
173
  'and it is not resumable.');
153
174
  }
@@ -161,7 +182,8 @@ export function renderRunManifest(view, context) {
161
182
  const pending = summary && summary.missingPhases && summary.missingPhases.length > 0
162
183
  ? `; not sealed yet: ${summary.missingPhases.join(', ')}`
163
184
  : '';
164
- return (`${head}recorded and coherent. ${kind}${sealed}${notApplicable}${pending}. This says the ` +
185
+ const absent = structurallyAbsentClause(summary);
186
+ return (`${head}recorded and coherent. ${kind}${sealed}${notApplicable}${pending}${absent}. This says the ` +
165
187
  'provenance CHAIN holds — the manifest matches the run\'s start record and ' +
166
188
  'carries no unrepaired failure. It is not a claim about the research findings.');
167
189
  }
@@ -1 +1 @@
1
- {"version":3,"file":"run-manifest.js","sourceRoot":"","sources":["../../src/tools/run-manifest.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"run-manifest.js","sourceRoot":"","sources":["../../src/tools/run-manifest.ts"],"names":[],"mappings":"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"}
@@ -43,6 +43,7 @@ import { apiCall, categorizeFetchError, promoteCompletedJob, webJobUrl, } from '
43
43
  import { safeInline, safeId, safeFactValue, truncate, firstQuoteWithCount } from './render-safety.js';
44
44
  import { permanentFailureMessage } from './permanent-failure.js';
45
45
  import { OperationIdSchema, operationRetryHint, resolveOperationId } from './operation-id.js';
46
+ import { buildDisambiguation, fetchRecentRuns } from './status.js';
46
47
  import { resolveMarketSignals, } from './market-signals.js';
47
48
  // Per-endpoint timeouts. Node fetch has no implicit timeout, so a hung endpoint would otherwise
48
49
  // wedge the MCP server forever (mirrors research.ts:START/EVENTS/REPORT_TIMEOUT_MS).
@@ -170,6 +171,27 @@ export const ScanMarketInputSchema = z.object({
170
171
  'discover competitors — use `scan_competitors` for the landscape.'),
171
172
  ...sharedScopedFields,
172
173
  });
174
+ /**
175
+ * FUL-1144 — what a dropped scoped call can recover by.
176
+ *
177
+ * ⚠️ NO `operation_id` HERE, deliberately. There is no read endpoint that maps an operation id to
178
+ * a job: the only thing that resolves one is `POST /api/validate/scoped/start`, and a status tool
179
+ * must never be a POST that can start (and charge for) a run. A caller holding its operation_id
180
+ * already has the right tool — re-calling the SAME scoped tool with the SAME operation_id and
181
+ * inputs replays the committed start without a second charge and re-attaches to it.
182
+ */
183
+ export const ScopedStatusInputSchema = z.object({
184
+ job_id: z
185
+ .string()
186
+ .uuid()
187
+ .optional()
188
+ .describe('UUID of the scoped run (scan_competitors / search_forums / scan_market) to check. ' +
189
+ 'OPTIONAL — omit it when a dropped call left you without one: this tool then lists your ' +
190
+ 'recent runs, each with its job_id and topic, so you can re-call with the one you were ' +
191
+ 'running. Where the id IS readable: the error text of a call that failed or timed out ' +
192
+ 'after starting. It is also printed to the MCP server stderr log as ' +
193
+ '`[clien-mcp] <tool> started job_id=<uuid>` — which reaches the human operator, not you.'),
194
+ });
173
195
  export class ScopedResearchToolError extends ToolError {
174
196
  }
175
197
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -212,6 +234,192 @@ export async function scanMarket(input, deps) {
212
234
  }
213
235
  return runScopedResearchTool('market', parsed.data, deps);
214
236
  }
237
+ /**
238
+ * `scoped_research_status` (FUL-1144) — recover a scoped run after a dropped call.
239
+ *
240
+ * The scoped tools are multi-minute synchronous calls; when the pipe drops mid-scan the job keeps
241
+ * running (and was paid for), but before this tool the caller had no way to read it back. This is
242
+ * the scoped counterpart of `clien_research_status`, built from the SAME pieces the live tools use
243
+ * so a recovered result cannot drift from a live one:
244
+ *
245
+ * - state + refund truth: one drained `/events` snapshot through `pollScopedJob`. The refund
246
+ * sentence is `scopedFailureText` → `buildFailureGuidance` over the server's own
247
+ * `refundedCredits` / `refundPending` / `errorCategory` / `startedSandbox`; nothing is inferred.
248
+ * - result: `fetchScopedReport` (the FUL-719 projection) → `buildScopedResult` (every renderer
249
+ * and its render-safety guards), exactly as the live call would have returned it.
250
+ * - persistence: the same idempotent `POST /promote` the live call ends with, so a run whose
251
+ * call dropped before that step reaches the Research tab now rather than on the ~10-minute
252
+ * sweep. The route answers `already_promoted` on a repeat.
253
+ * - discovery: `fetchRecentRuns` + `buildDisambiguation` from `status.ts`.
254
+ */
255
+ export async function getScopedResearchStatus(input, deps) {
256
+ const parsed = ScopedStatusInputSchema.safeParse(input ?? {});
257
+ if (!parsed.success) {
258
+ throw new ScopedResearchToolError(`Invalid input — ${formatZodError(parsed.error)}`);
259
+ }
260
+ const fetchImpl = deps.fetch ?? fetch;
261
+ const ctx = { config: deps.config, session: deps.session, fetch: fetchImpl };
262
+ let jobId = parsed.data.job_id;
263
+ if (!jobId) {
264
+ // ⚠️ NEVER AUTO-PICK FROM A LIST OF SEVERAL. `GET /api/validate` returns hero and scoped runs
265
+ // together with nothing to tell them apart (the summary row carries no mode), so "the one
266
+ // active run" may be a `clien_research` run while the scan the caller lost has already
267
+ // finished. Only a list of exactly one is unambiguous; anything else is shown, not guessed.
268
+ const jobs = await fetchRecentRuns(ctx, deps, 'scan_competitors, search_forums or scan_market');
269
+ const [only] = jobs;
270
+ if (!only || jobs.length > 1)
271
+ return buildDisambiguation(jobs, 'recent', SCOPED_STATUS_TOOL);
272
+ // A server-sent id: state it only if it can be stated exactly.
273
+ const serverId = safeId(only.id);
274
+ if (!serverId) {
275
+ throw new ScopedResearchToolError('Your one recent run came back with an unreadable job id. Please try again.');
276
+ }
277
+ jobId = serverId;
278
+ }
279
+ const poll = await pollScopedJob(ctx, jobId, {
280
+ sleep: deps.sleep ?? defaultSleep,
281
+ pollIntervalMs: deps.pollIntervalMs ?? deps.config.pollIntervalMs,
282
+ deadline: Number.POSITIVE_INFINITY,
283
+ onProgress: deps.onProgress,
284
+ invalidateSession: deps.invalidateSession,
285
+ label: 'Research run',
286
+ config: deps.config,
287
+ projectId: null,
288
+ snapshot: true,
289
+ });
290
+ const projectId = poll.projectId ?? null;
291
+ const jobLink = webJobUrl(deps.config, jobId, projectId);
292
+ const status = safeInline(poll.status, 40) ?? 'unknown';
293
+ const base = {
294
+ job_id: jobId,
295
+ status: poll.status,
296
+ project_id: projectId,
297
+ promoted: false,
298
+ promotion_error_code: null,
299
+ promotion_retryable: null,
300
+ report_data: null,
301
+ ...(poll.totalCostCents !== undefined ? { total_cost_cents: poll.totalCostCents } : {}),
302
+ };
303
+ if (!TERMINAL_STATUSES.has(poll.status)) {
304
+ const activity = poll.lastMessage ? ` Latest activity: ${poll.lastMessage}.` : '';
305
+ return {
306
+ content: [
307
+ {
308
+ type: 'text',
309
+ text: `Job ${jobId} is still running (phase: "${status}").${activity} It keeps running on ` +
310
+ `the server — do NOT start it again. Call ${SCOPED_STATUS_TOOL} with this same job_id ` +
311
+ `in a minute or two, or view ${jobLink}.`,
312
+ },
313
+ ],
314
+ _meta: { ...base, done: false },
315
+ };
316
+ }
317
+ if (poll.status !== 'complete') {
318
+ return {
319
+ content: [
320
+ {
321
+ type: 'text',
322
+ text: `Job ${jobId}: ${scopedFailureText({ label: 'Research run', noun: 'results' }, poll, jobLink)}`,
323
+ },
324
+ ],
325
+ _meta: {
326
+ ...base,
327
+ done: true,
328
+ ...(poll.errorCategory !== undefined ? { error_category: poll.errorCategory } : {}),
329
+ ...(poll.refundedCredits !== undefined ? { refunded_credits: poll.refundedCredits } : {}),
330
+ ...(poll.refundPending ? { refund_pending: true } : {}),
331
+ },
332
+ };
333
+ }
334
+ const { reportData, hasMarkdown } = await fetchScopedReport(ctx, jobId, deps.config, projectId);
335
+ // Only a full `clien_research` run writes report markdown. Its report owes the trust digest,
336
+ // which this tool does not render — so hand it to the tool that does rather than a partial view.
337
+ if (hasMarkdown) {
338
+ return {
339
+ content: [
340
+ {
341
+ type: 'text',
342
+ text: `Job ${jobId} is a full clien_research run, not a scoped scan. Call ` +
343
+ 'clien_research_status (or get_report) with this same job_id for its report and trust digest.',
344
+ },
345
+ ],
346
+ _meta: { ...base, done: true },
347
+ };
348
+ }
349
+ const mode = scopedModeOf(reportData);
350
+ if (!mode) {
351
+ return {
352
+ content: [
353
+ {
354
+ type: 'text',
355
+ text: `Job ${jobId} completed, but its stored result carries no competitor, community or ` +
356
+ `market section, so there is nothing to show here. View ${jobLink}.`,
357
+ },
358
+ ],
359
+ _meta: { ...base, done: true, report_data: reportData },
360
+ };
361
+ }
362
+ const promotion = projectId ? await promoteScopedJob(ctx, jobId) : NO_PROMOTION;
363
+ const result = buildScopedResult({
364
+ mode,
365
+ jobId,
366
+ status: poll.status,
367
+ projectId,
368
+ promotion,
369
+ reportData,
370
+ // The list and events routes do not return the topic for a job id; the renderers fall back to
371
+ // "this topic" rather than printing a topic nobody stated.
372
+ topic: '',
373
+ totalCostCents: poll.totalCostCents,
374
+ });
375
+ const [first] = result.content;
376
+ return {
377
+ ...result,
378
+ content: [
379
+ {
380
+ type: 'text',
381
+ text: `Recovered ${MODE_META[mode].tool} job ${jobId} (complete).\n\n${first?.text ?? ''}`,
382
+ },
383
+ ],
384
+ _meta: { ...result._meta, done: true, mode },
385
+ };
386
+ }
387
+ const SCOPED_STATUS_TOOL = 'scoped_research_status';
388
+ /**
389
+ * The drop-recovery paragraph every scoped tool's description ends with (FUL-1144). One function,
390
+ * so the three adverts cannot drift apart about how a paid, still-running scan is recovered.
391
+ */
392
+ export function scopedDropRecoveryAdvert(tool) {
393
+ return ('IMPORTANT: if the call drops mid-flight with "MCP error -32000", the job keeps running on the ' +
394
+ 'backend — do NOT start it again (a fresh call starts a new run and debits again). Recover it: ' +
395
+ `call \`${SCOPED_STATUS_TOOL}\` — with NO arguments it lists your recent runs (job_id + topic) ` +
396
+ 'so you can pick this one, then re-call it with that job_id for the state, the result, or the ' +
397
+ 'refund outcome. If you set an `operation_id`, re-calling this tool with the SAME operation_id ' +
398
+ 'and the SAME inputs also re-attaches to the run without a second charge. The job_id is logged ' +
399
+ `to the MCP server stderr as \`[clien-mcp] ${tool} started job_id=…\`, which reaches the human ` +
400
+ 'operator rather than you.');
401
+ }
402
+ /**
403
+ * Which scoped researcher produced `reportData`, read from the one envelope each mode writes
404
+ * (`agent/src/scoped-research.ts`: `webResearch` / `forumResearch` / `marketResearch`, never two).
405
+ * `null` when none is present — a run that recorded no findings — or, defensively, more than one.
406
+ */
407
+ function scopedModeOf(reportData) {
408
+ if (!reportData)
409
+ return null;
410
+ const present = (key) => {
411
+ const value = reportData[key];
412
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
413
+ };
414
+ const modes = [];
415
+ if (present('webResearch'))
416
+ modes.push('web');
417
+ if (present('forumResearch'))
418
+ modes.push('forum');
419
+ if (present('marketResearch'))
420
+ modes.push('market');
421
+ return modes.length === 1 ? modes[0] : null;
422
+ }
215
423
  async function runScopedResearchTool(mode, input, deps) {
216
424
  const { topic, product_name, project_id, project_name } = input;
217
425
  // FUL-810: one paid start, one id — see operation-id.ts.
@@ -287,52 +495,17 @@ async function runScopedResearchTool(mode, input, deps) {
287
495
  // outcome (the user's money) and a cause-keyed retry hint so the calling
288
496
  // agent advises correctly without re-deriving intent from the prose.
289
497
  if (poll.status !== 'complete') {
290
- const reason = poll.lastMessage ? ` (${poll.lastMessage})` : '';
291
498
  // Shared with clien_research_status — the refund note is an ELIGIBILITY
292
499
  // hint, not a promise (see buildFailureGuidance). NOTE: on a THROWN scoped
293
500
  // failure this reaches the caller only as prose (ToolError carries no
294
501
  // structured _meta); an agent that needs the machine-readable fields calls
295
- // clien_research_status, which returns them in _meta. (review follow-up.)
296
- const { refundNote, retryHint, retryPointless } = buildFailureGuidance({
297
- errorCategory: poll.errorCategory,
298
- refundedCredits: poll.refundedCredits,
299
- refundPending: poll.refundPending,
300
- startedSandbox: poll.startedSandbox,
301
- });
302
- /**
303
- * ⚠️ THE CLOSING INSTRUCTION IS PART OF THE RETRY ADVICE (FUL-845 round-5).
304
- *
305
- * "Try a more specific topic" used to close every one of these, including
306
- * the branch where `retryHint` has just said, in the same sentence, that
307
- * retrying will not help until we fix our own configuration. A calling
308
- * agent reading both does the thing it was last told to do: it re-words the
309
- * topic and buys the same failure again. The run never reached a sandbox —
310
- * the topic was never read by anything, so it cannot be the problem.
311
- *
312
- * The link stays on both branches: it is where the run's own words are.
313
- */
314
- const jobLink = webJobUrl(deps.config, jobId, projectId);
315
- const nextStep = retryPointless
316
- ? `View ${jobLink}.`
317
- : `Try a more specific topic, or view ${jobLink}.`;
318
- throw new ScopedResearchToolError(`${meta.label} ${poll.status === 'failed' ? 'failed' : 'was cancelled'}${reason}. ` +
319
- `No ${meta.noun} were produced.${refundNote}${retryHint} ${nextStep}`, { jobId });
502
+ // scoped_research_status, which returns them in _meta. (review follow-up.)
503
+ throw new ScopedResearchToolError(scopedFailureText(meta, poll, webJobUrl(deps.config, jobId, projectId)), { jobId });
320
504
  }
321
505
  // 5. Promote into the Research collections (best-effort — needs a project; standalone runs skip).
322
- let promotion = {
323
- promoted: false,
324
- errorCode: null,
325
- retryable: null,
326
- // FUL-479: a scoped `scan_market` run IS eligible for market authority now, so this is no
327
- // longer null on every path — `promoteScopedJob` fills it from the server's own discriminator
328
- // below, and `buildScopedResult` refuses to claim a saved snapshot without it.
329
- marketUpdated: null,
330
- };
331
- if (projectId) {
332
- promotion = await promoteScopedJob(ctx, jobId);
333
- }
506
+ const promotion = projectId ? await promoteScopedJob(ctx, jobId) : NO_PROMOTION;
334
507
  // 6. Fetch the structured report.
335
- const reportData = await fetchScopedReport(ctx, jobId, deps.config, projectId);
508
+ const { reportData } = await fetchScopedReport(ctx, jobId, deps.config, projectId);
336
509
  // 7. Extract the RICH slice + build the result.
337
510
  return buildScopedResult({
338
511
  mode,
@@ -345,6 +518,49 @@ async function runScopedResearchTool(mode, input, deps) {
345
518
  totalCostCents: poll.totalCostCents,
346
519
  });
347
520
  }
521
+ /**
522
+ * No promotion attempted (a standalone run). FUL-479: a scoped `scan_market` run IS eligible for
523
+ * market authority, so `marketUpdated` is not null on every path — `promoteScopedJob` fills it from
524
+ * the server's own discriminator, and `buildScopedResult` refuses to claim a saved snapshot without it.
525
+ */
526
+ const NO_PROMOTION = {
527
+ promoted: false,
528
+ errorCode: null,
529
+ retryable: null,
530
+ marketUpdated: null,
531
+ };
532
+ /**
533
+ * The sentence a failed or cancelled scoped run is reported with — by the live tool (thrown) and by
534
+ * `scoped_research_status` (returned). One copy, so the recovery read can never word the refund
535
+ * differently from the call it recovers. The refund note comes from the SERVER's own fields via
536
+ * `buildFailureGuidance`; nothing here infers a refund.
537
+ */
538
+ function scopedFailureText(meta, poll, jobLink) {
539
+ const reason = poll.lastMessage ? ` (${poll.lastMessage})` : '';
540
+ const { refundNote, retryHint, retryPointless } = buildFailureGuidance({
541
+ errorCategory: poll.errorCategory,
542
+ refundedCredits: poll.refundedCredits,
543
+ refundPending: poll.refundPending,
544
+ startedSandbox: poll.startedSandbox,
545
+ });
546
+ /**
547
+ * ⚠️ THE CLOSING INSTRUCTION IS PART OF THE RETRY ADVICE (FUL-845 round-5).
548
+ *
549
+ * "Try a more specific topic" used to close every one of these, including
550
+ * the branch where `retryHint` has just said, in the same sentence, that
551
+ * retrying will not help until we fix our own configuration. A calling
552
+ * agent reading both does the thing it was last told to do: it re-words the
553
+ * topic and buys the same failure again. The run never reached a sandbox —
554
+ * the topic was never read by anything, so it cannot be the problem.
555
+ *
556
+ * The link stays on both branches: it is where the run's own words are.
557
+ */
558
+ const nextStep = retryPointless
559
+ ? `View ${jobLink}.`
560
+ : `Try a more specific topic, or view ${jobLink}.`;
561
+ return (`${meta.label} ${poll.status === 'failed' ? 'failed' : 'was cancelled'}${reason}. ` +
562
+ `No ${meta.noun} were produced.${refundNote}${retryHint} ${nextStep}`);
563
+ }
348
564
  /**
349
565
  * Poll /events until a terminal status. Backs off on transient 5xx / poll timeouts (2s,5s,10s, then
350
566
  * bail after 3 consecutive), invalidates the session on a genuine 401, and enforces `deadline`.
@@ -359,14 +575,17 @@ async function pollScopedJob(ctx, jobId, deps) {
359
575
  let refundedCredits;
360
576
  let refundPending = false;
361
577
  let startedSandbox;
578
+ let projectId;
362
579
  let hasMore = false;
363
580
  let consecutiveServerErrors = 0;
364
581
  while (!TERMINAL_STATUSES.has(status) || hasMore) {
365
- if (!TERMINAL_STATUSES.has(status) && Date.now() > deps.deadline) {
582
+ if (!deps.snapshot && !TERMINAL_STATUSES.has(status) && Date.now() > deps.deadline) {
366
583
  throw new ScopedResearchToolError(`${deps.label} did not complete within the allotted time. The job may still be running — ` +
367
584
  `check ${webJobUrl(deps.config, jobId, deps.projectId)}.`, { jobId });
368
585
  }
369
- await deps.sleep(deps.pollIntervalMs);
586
+ // A snapshot reads immediately: nothing is being waited for.
587
+ if (!deps.snapshot)
588
+ await deps.sleep(deps.pollIntervalMs);
370
589
  let response;
371
590
  try {
372
591
  response = await apiCall(ctx, 'GET', `/api/validate/${jobId}/events?since=${lastSequence}&limit=${hasMore ? 1000 : 100}`, {
@@ -401,6 +620,9 @@ async function pollScopedJob(ctx, jobId, deps) {
401
620
  refundPending = data?.job?.refundPending === true;
402
621
  if (typeof data?.job?.startedSandbox === 'boolean')
403
622
  startedSandbox = data.job.startedSandbox;
623
+ if (typeof data?.job?.projectId === 'string' || data?.job?.projectId === null) {
624
+ projectId = data.job.projectId;
625
+ }
404
626
  if (typeof data?.lastSequence === 'number')
405
627
  lastSequence = data.lastSequence;
406
628
  const summary = summarizeEvent(data?.events);
@@ -409,10 +631,20 @@ async function pollScopedJob(ctx, jobId, deps) {
409
631
  await emitProgress(deps, {
410
632
  progress: PHASE_PROGRESS[status] ?? 0.5,
411
633
  total: 1,
412
- message: summary ?? `phase: ${status}`,
634
+ // FUL-1254: the server's job status, flattened like the event text `summary` carries.
635
+ message: summary ?? `phase: ${safeInline(status, 40) ?? 'unknown'}`,
413
636
  });
637
+ // Snapshot: stop once the pages are drained, whatever the status.
638
+ if (deps.snapshot && !hasMore)
639
+ break;
414
640
  continue;
415
641
  }
642
+ // The events route answers 404 for a job that does not exist OR belongs to another account —
643
+ // deliberately indistinguishable, so this says both.
644
+ if (response.status === 404) {
645
+ throw new ScopedResearchToolError(`No research job found with id ${jobId} on this account. The id may belong to a different ` +
646
+ 'account, may be malformed, or may never have existed.', { jobId });
647
+ }
416
648
  const category = categorizeFetchError(response.status);
417
649
  if (category === 'auth') {
418
650
  if (response.refreshError?.kind === 'transient') {
@@ -434,7 +666,16 @@ async function pollScopedJob(ctx, jobId, deps) {
434
666
  const text = await response.text().catch(() => '');
435
667
  throw new ScopedResearchToolError(`Unexpected error polling ${deps.label.toLowerCase()} events (HTTP ${response.status}): ${truncate(text, 200)}`, { jobId });
436
668
  }
437
- return { status, lastMessage, totalCostCents, errorCategory, refundedCredits, refundPending, startedSandbox };
669
+ return {
670
+ status,
671
+ lastMessage,
672
+ totalCostCents,
673
+ errorCategory,
674
+ refundedCredits,
675
+ refundPending,
676
+ startedSandbox,
677
+ ...(projectId !== undefined ? { projectId } : {}),
678
+ };
438
679
  }
439
680
  /**
440
681
  * Backoff helper for transient poll failures. Returns true if the caller should `continue` (backed
@@ -472,7 +713,11 @@ async function promoteScopedJob(ctx, jobId) {
472
713
  }
473
714
  return outcome;
474
715
  }
475
- /** GET the structured report (JSON variant). Returns report_data (may be null). */
716
+ /**
717
+ * GET the structured report (JSON variant). Returns report_data (may be null) and whether the job
718
+ * carries report MARKDOWN — which only a full `clien_research` run writes, so the recovery read can
719
+ * tell a hero job id from a scoped one without guessing from the payload's shape.
720
+ */
476
721
  async function fetchScopedReport(ctx, jobId, config, projectId) {
477
722
  let response;
478
723
  try {
@@ -508,7 +753,10 @@ async function fetchScopedReport(ctx, jobId, config, projectId) {
508
753
  //
509
754
  // Projected at the FETCH boundary rather than at the emit site so nothing downstream — the
510
755
  // extractors included — ever holds the raw payload.
511
- return projectReportDataForMcp(json?.reportData ?? null) ?? null;
756
+ return {
757
+ reportData: projectReportDataForMcp(json?.reportData ?? null) ?? null,
758
+ hasMarkdown: typeof json?.reportMarkdown === 'string' && json.reportMarkdown.trim().length > 0,
759
+ };
512
760
  }
513
761
  // ------------------------------------------------------------------
514
762
  // Result building
@@ -1078,12 +1326,19 @@ function summarizeEvent(events) {
1078
1326
  const last = events[events.length - 1];
1079
1327
  if (!last)
1080
1328
  return null;
1081
- const type = last.eventType ?? 'event';
1329
+ // FUL-1144: event text is server/agent-written and lands in model-visible prose — in a thrown
1330
+ // failure line here, and in `scoped_research_status`'s returned text — so it is flattened, not
1331
+ // just clipped. `truncate` bounds length; only `safeInline` stops a newline forging a line.
1332
+ const type = safeInline(last.eventType, 40) ?? 'event';
1082
1333
  const data = last.eventData ?? {};
1083
- if (typeof data.message === 'string' && data.message.length > 0)
1084
- return truncate(`${type}: ${data.message}`, 120);
1085
- if (typeof data.phase === 'string')
1086
- return truncate(`${type}: ${data.phase}`, 120);
1334
+ if (typeof data.message === 'string') {
1335
+ const message = safeInline(data.message, 120);
1336
+ if (message)
1337
+ return truncate(`${type}: ${message}`, 120);
1338
+ }
1339
+ const phase = safeInline(data.phase, 40);
1340
+ if (phase)
1341
+ return truncate(`${type}: ${phase}`, 120);
1087
1342
  return type;
1088
1343
  }
1089
1344
  function formatZodError(error) {