@link-assistant/hive-mind 2.11.11 → 2.11.12

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.11.12
4
+
5
+ ### Patch Changes
6
+
7
+ - be63949: Render structured tool error payloads as readable text instead of
8
+ `[object Object]` (issue #2141).
9
+
10
+ A `solve --tool agent --model formal-ai` run failed 22 seconds in and published
11
+ one artefact: a "Solution Draft Failed" comment reading `AGENT execution failed
12
+ with Agent reported error: [object Object]`. `--attach-logs` was off, so that
13
+ string was the entire post-mortem and the real cause is unrecoverable.
14
+ `@link-assistant/agent` emits `NamedError.toObject()` — `{"type":"error",
15
+ "error":{"name":"…","data":{"message":"…"}}}` — and the adapter interpolated that
16
+ object into a template literal. The `|| JSON.stringify(msg)` fallback that would
17
+ have saved the diagnosis was unreachable, because the object is truthy.
18
+
19
+ - New `src/error-text.lib.mjs` renders strings, `Error` instances, `NamedError`
20
+ payloads, nested `{error:{…}}` envelopes and arrays into one readable line —
21
+ circular-safe, depth-limited, truncated at 2000 characters, never a
22
+ placeholder.
23
+ - A codebase-wide audit found the same defect class in nine places across six
24
+ adapters; all now use the shared renderer. One of them was not cosmetic:
25
+ `codex.lib.mjs` guarded `error` / `turn.failed` events with `typeof
26
+ data.message === 'string'` and therefore **discarded** object-shaped failures,
27
+ reporting the run as a success.
28
+ - Defence in depth: `isMeaningfulErrorText` and `extractToolErrorCore` now reject
29
+ a core polluted by `[object Object]`, so any site this audit missed degrades to
30
+ the honest `AGENT execution failed` rather than the misleading long form.
31
+ - The agent adapter now fails fast when the CLI logs a fatal startup error
32
+ (`ProviderModelNotFoundError` and friends) and then exits 0 with no error event
33
+ and no output — a silent failure reproduced against agent CLI 0.25.5 and
34
+ reported upstream.
35
+ - `--verbose` dumps the raw JSON of every error and fatal log record, and the
36
+ pre-PR failure comment now tells the reader to rerun with
37
+ `--attach-logs --verbose`.
38
+
39
+ Case study, raw evidence and the upstream reports:
40
+ `docs/case-studies/issue-2141/README.md`.
41
+
3
42
  ## 2.11.11
4
43
 
5
44
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.11",
3
+ "version": "2.11.12",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/agent.lib.mjs CHANGED
@@ -27,12 +27,98 @@ import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue
27
27
  import { checkPlaywrightMcpPackageAvailability, getAgentPlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
28
28
  import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
29
29
  import { createJsonStreamScanner, parseJsonRecords } from './json-stream.lib.mjs';
30
+ import { firstErrorText, stringifyErrorValue } from './error-text.lib.mjs';
30
31
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
31
32
  import { attachStreamingInput, finalizeBidirectionalHandler, setupBidirectionalHandler } from './bidirectional-interactive.lib.mjs';
32
33
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
33
34
 
34
35
  export { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage };
35
36
 
37
+ /**
38
+ * Render one streamed agent error record as human-readable text (issue #2141).
39
+ *
40
+ * `@link-assistant/agent` 0.25.x publishes `NamedError.toObject()` under the
41
+ * `error` key: `{"type":"error","error":{"name":"RetryTimeoutExceededError",
42
+ * "data":{"message":"…"}}}`. The previous chain `data.message || data.error ||
43
+ * raw.substring(0, 100)` returned that *object*, and interpolating it into
44
+ * `Agent reported error: ${…}` produced the reason published to GitHub in issue
45
+ * #2141: "AGENT execution failed with Agent reported error: [object Object]".
46
+ *
47
+ * @param {object} record - a sanitized JSON record from the agent stream.
48
+ * @param {string} [raw] - the raw record text, used as a last resort.
49
+ * @returns {string} readable error text, never `[object Object]`.
50
+ */
51
+ export const extractAgentErrorText = (record, raw = '') => {
52
+ const fallback = String(raw || '').substring(0, 200) || 'Agent emitted an error event without any details';
53
+ return firstErrorText([record?.message, record?.error, record?.data, record], { fallback });
54
+ };
55
+
56
+ /**
57
+ * Model/provider initialization failures that leave the session unable to do any
58
+ * work at all (issue #2141).
59
+ */
60
+ const FATAL_AGENT_LOG_PATTERNS = [/ProviderModelNotFoundError/i, /ProviderInitError/i, /NoSuchModelError/i, /ModelNotFoundError/i, /failed to initialize .*model/i];
61
+
62
+ /**
63
+ * Detect an agent `log` record that reports a fatal startup failure (issue #2141).
64
+ *
65
+ * Reproduced locally with agent CLI 0.25.5: `agent --model nonexistent/model`
66
+ * prints
67
+ *
68
+ * {"type":"log","level":"error","service":"session.prompt",
69
+ * "error":"ProviderModelNotFoundError",
70
+ * "hint":"Check that the model exists in the provider",
71
+ * "message":"Failed to initialize specified model - NOT falling back to default"}
72
+ *
73
+ * then `session.idle` and exits **0** without ever emitting `{"type":"error"}`.
74
+ * Hive Mind therefore reported the run as a success with no result summary, which
75
+ * is the same class of undiagnosable outcome as issue #2141's `[object Object]`:
76
+ * the run failed, but nothing said so.
77
+ *
78
+ * @param {object} record - a sanitized JSON record from the agent stream.
79
+ * @returns {string|null} readable failure text, or null when the record is not fatal.
80
+ */
81
+ export const detectFatalAgentLogRecord = record => {
82
+ if (!record || typeof record !== 'object') return null;
83
+ if (record.type !== 'log' || record.level !== 'error') return null;
84
+
85
+ const text = firstErrorText([record.error, record.message], { fallback: '' });
86
+ if (!text) return null;
87
+ const haystack = `${stringifyErrorValue(record.error)} ${stringifyErrorValue(record.message)}`;
88
+ if (!FATAL_AGENT_LOG_PATTERNS.some(pattern => pattern.test(haystack))) return null;
89
+
90
+ const parts = [stringifyErrorValue(record.error), stringifyErrorValue(record.message), record.hint ? `hint: ${stringifyErrorValue(record.hint)}` : ''];
91
+ return parts.filter(Boolean).join(' — ');
92
+ };
93
+
94
+ /**
95
+ * Scan agent stdout for explicit JSON error records (issues #1201, #2119, #2141).
96
+ *
97
+ * Exported so the detection precedence can be tested directly instead of being
98
+ * re-implemented in tests (the old duplicate in tests/test-agent-error-detection.mjs
99
+ * kept the `[object Object]` bug invisible).
100
+ *
101
+ * @param {string} stdoutOutput - captured agent stdout.
102
+ * @returns {{detected: boolean, type?: string, match?: string, record?: object}}
103
+ */
104
+ export const detectAgentErrorsInOutput = stdoutOutput => {
105
+ // Issue #2119: frame records by balanced JSON, not by newlines, so
106
+ // pretty-printed and concatenated records are still inspected.
107
+ for (const record of parseJsonRecords(stdoutOutput)) {
108
+ const msg = sanitizeObjectStrings(record);
109
+
110
+ // Issue #1968: ignore bare `null`/primitive records (msg.type would throw on null).
111
+ if (msg === null || typeof msg !== 'object') continue;
112
+
113
+ // Check for explicit error message types from agent
114
+ if (msg.type === 'error' || msg.type === 'step_error') {
115
+ return { detected: true, type: 'AgentError', match: extractAgentErrorText(msg, JSON.stringify(msg)), record: msg };
116
+ }
117
+ }
118
+
119
+ return { detected: false };
120
+ };
121
+
36
122
  // Import pricing functions from claude.lib.mjs
37
123
  // We reuse fetchModelInfo and checkModelVisionCapability to get data from models.dev API
38
124
  const claudeLib = await import('./claude.lib.mjs');
@@ -608,6 +694,9 @@ export const executeAgentCommand = async params => {
608
694
  // Issue #1276: Track successful completion events to clear error flags
609
695
  // When agent emits session.idle or disposal events, it means it recovered and completed successfully
610
696
  let agentCompletedSuccessfully = false;
697
+ // Issue #2141: a fatal startup log record (e.g. ProviderModelNotFoundError)
698
+ // that the agent CLI reports without any `{"type":"error"}` event.
699
+ let fatalLogErrorMessage = null;
611
700
  // Issue #1250: Accumulate token usage during streaming instead of parsing fullOutput later
612
701
  // This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
613
702
  const streamingTokenUsage = createAgentTokenUsage();
@@ -668,8 +757,23 @@ export const executeAgentCommand = async params => {
668
757
  // Issue #1201: Detect error events during streaming for reliable detection
669
758
  if (data.type === 'error' || data.type === 'step_error') {
670
759
  streamingErrorDetected = true;
671
- streamingErrorMessage = data.message || data.error || raw.substring(0, 100);
760
+ // Issue #2141: render `{name, data:{message}}` payloads as text so the
761
+ // published failure reason is diagnosable instead of "[object Object]".
762
+ streamingErrorMessage = extractAgentErrorText(data, raw);
672
763
  await log(`⚠️ Error event detected in stream: ${streamingErrorMessage}`, { level: 'warning' });
764
+ // Issue #2141: keep the untouched record so the root cause survives even
765
+ // when the rendering above loses a field. Verbose-only to keep normal
766
+ // logs readable; --attach-logs then carries the full payload.
767
+ await log(` Raw error record: ${JSON.stringify(data)}`, { level: 'warning', verbose: true });
768
+ }
769
+ // Issue #2141: fail fast when the CLI could not even start the model.
770
+ if (!fatalLogErrorMessage) {
771
+ const fatalLogText = detectFatalAgentLogRecord(data);
772
+ if (fatalLogText) {
773
+ fatalLogErrorMessage = fatalLogText;
774
+ await log(`⚠️ Fatal agent log record detected: ${fatalLogText}`, { level: 'warning' });
775
+ await log(` Raw log record: ${JSON.stringify(data)}`, { level: 'warning', verbose: true });
776
+ }
673
777
  }
674
778
  // Issue #1263: Track text content for result summary
675
779
  // Agent outputs text via 'text', 'assistant', or 'message' type events
@@ -759,26 +863,9 @@ export const executeAgentCommand = async params => {
759
863
  // 1. Non-zero exit code (agent returns 1 on errors)
760
864
  // 2. Explicit JSON error messages from agent (type: "error")
761
865
  // 3. Usage limit detection (handled separately)
762
- const detectAgentErrors = stdoutOutput => {
763
- // Issue #2119: frame records by balanced JSON, not by newlines, so
764
- // pretty-printed and concatenated records are still inspected.
765
- for (const record of parseJsonRecords(stdoutOutput)) {
766
- const msg = sanitizeObjectStrings(record);
767
-
768
- // Issue #1968: ignore bare `null`/primitive records (msg.type would throw on null).
769
- if (msg === null || typeof msg !== 'object') continue;
770
-
771
- // Check for explicit error message types from agent
772
- if (msg.type === 'error' || msg.type === 'step_error') {
773
- return { detected: true, type: 'AgentError', match: msg.message || msg.error || JSON.stringify(msg).substring(0, 100) };
774
- }
775
- }
776
-
777
- return { detected: false };
778
- };
779
-
780
866
  // Only check for JSON error messages, not pattern matching in output
781
- const outputError = detectAgentErrors(fullOutput);
867
+ // Issue #2141: the detection now renders structured payloads as text.
868
+ const outputError = detectAgentErrorsInOutput(fullOutput);
782
869
 
783
870
  // Issue #1276: Clear streaming error detection if agent completed successfully
784
871
  // When an error occurs during execution (e.g., timeout) but the agent recovers and completes,
@@ -857,6 +944,18 @@ export const executeAgentCommand = async params => {
857
944
  }
858
945
  }
859
946
 
947
+ // Issue #2141: agent CLI 0.25.5 exits 0 after `ProviderModelNotFoundError`
948
+ // without emitting an error event, so the run was published as a success
949
+ // with no result summary. Treat a fatal startup log record as the failure
950
+ // it is, but only when the session produced no work at all — a recovered
951
+ // error must keep exit code 0 authoritative (issue #1276).
952
+ if (exitCode === 0 && !outputError.detected && fatalLogErrorMessage && !lastTextContent) {
953
+ outputError.detected = true;
954
+ outputError.type = 'AgentFatalLog';
955
+ outputError.match = fatalLogErrorMessage;
956
+ await log(`\n⚠️ Agent exited 0 but never started a model: ${fatalLogErrorMessage}`, { level: 'warning' });
957
+ }
958
+
860
959
  if (exitCode !== 0 || outputError.detected) {
861
960
  const retryableError = classifyRetryableError(outputError.match || streamingErrorMessage || lastMessage || fullOutput);
862
961
  if (retryableError.isRetryable) {
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { CANCELLED_CI_REVIEW_MARKER } from './tool-comments.lib.mjs';
15
+ import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
15
16
 
16
17
  const CANCELLED_OR_STALE_CONCLUSIONS = new Set(['cancelled', 'stale']);
17
18
 
@@ -122,7 +123,8 @@ const formatRunReference = run => {
122
123
 
123
124
  const formatRerunFailure = failure => {
124
125
  const runPart = formatRunReference(failure?.run);
125
- const error = failure?.error || 'Unknown error';
126
+ // Issue #2141: the error may arrive as an object; never publish [object Object].
127
+ const error = stringifyErrorValue(failure?.error, { fallback: 'Unknown error' });
126
128
  return `${runPart}: ${error}`;
127
129
  };
128
130
 
@@ -36,6 +36,7 @@ import { createThinkingBlockRecovery } from './claude.thinking-block-recovery.li
36
36
  import { buildMissingClaudeResultMessage, collectClaudeStreamEventFacts, getClaudeMessageContent, shouldFailClaudeStreamWithoutResult } from './claude.stream-events.lib.mjs';
37
37
  import { formatNumber, mapModelToId, checkModelVisionCapability } from './claude.model-utils.lib.mjs';
38
38
  import { showResumeCommand } from './claude.resume-output.lib.mjs';
39
+ import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
39
40
  import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
40
41
  export { availableModels, fetchModelInfo }; // Re-export for backward compatibility
41
42
  export { formatNumber, mapModelToId, checkModelVisionCapability };
@@ -96,7 +97,7 @@ export const validateClaudeConnection = async (model = 'haiku') => {
96
97
  const jsonMatch = text.match(/\{.*"error".*\}/);
97
98
  if (jsonMatch) {
98
99
  const errorObj = JSON.parse(jsonMatch[0]);
99
- return errorObj.error;
100
+ return stringifyErrorValue(errorObj.error, { fallback: jsonMatch[0] }); // Issue #2141: the `error` field is usually an object
100
101
  }
101
102
  }
102
103
  } catch (e) {
@@ -936,7 +937,7 @@ export const executeClaudeCommand = async params => {
936
937
  }
937
938
  if (data.type === 'text' && data.text) lastMessage = data.text;
938
939
  else if (data.type === 'error') {
939
- lastMessage = data.error || JSON.stringify(data);
940
+ lastMessage = stringifyErrorValue(data.error, { fallback: JSON.stringify(data) }); // Issue #2141: `data.error` is often an object; render it as text so the reason is never "[object Object]" and the substring checks below get a real string
940
941
  if (lastMessage.includes('Internal server error')) isInternalServerError = true;
941
942
  }
942
943
  // Issue #1491: Track token usage from stream events for independent calculation
package/src/codex.lib.mjs CHANGED
@@ -25,6 +25,7 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
25
25
  import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
26
26
  const __codexBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'codex', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
27
27
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
28
+ import { firstErrorText } from './error-text.lib.mjs'; // Issue #2141
28
29
  import { createLineBuffer } from './json-stream.lib.mjs'; // Issue #2119
29
30
  import { mapModelToId, resolveCodexReasoningEffort } from './codex.options.lib.mjs';
30
31
  import { buildCodexRunDiagnostics, codexRunAlreadyFailed, describeCodexLastMessageOutcome } from './codex.run-diagnostics.lib.mjs'; // Issue #2130
@@ -410,20 +411,20 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
410
411
  if (typeof getter(data) === 'string') observedModelPaths.add(pathName);
411
412
  }
412
413
 
413
- if (eventType === 'error' && typeof data.message === 'string' && (data.message.includes('401 Unauthorized') || data.message.includes('401') || data.message.includes('Unauthorized'))) {
414
- nextState.authError = true;
414
+ // Issue #2141: these payloads are not always strings. Requiring `typeof
415
+ // === 'string'` dropped every object-shaped failure — a dropped
416
+ // `turn.failed` reads as a success — and interpolating one would have
417
+ // published "[object Object]". Render it as text instead.
418
+ const streamErrorText = eventType === 'error' ? firstErrorText([data.message, data.error]) : '';
419
+ if (streamErrorText) {
420
+ if (streamErrorText.includes('401') || streamErrorText.includes('Unauthorized')) nextState.authError = true;
421
+ nextState.streamErrors.push({ message: streamErrorText });
415
422
  }
416
423
 
417
- if (eventType === 'error' && typeof data.message === 'string') {
418
- nextState.streamErrors.push({ message: data.message });
419
- }
420
-
421
- if (eventType === 'turn.failed' && typeof data.error?.message === 'string' && (data.error.message.includes('401 Unauthorized') || data.error.message.includes('401') || data.error.message.includes('Unauthorized'))) {
422
- nextState.authError = true;
423
- }
424
-
425
- if (eventType === 'turn.failed' && typeof data.error?.message === 'string') {
426
- nextState.turnFailures.push({ message: data.error.message });
424
+ const turnFailureText = eventType === 'turn.failed' ? firstErrorText([data.error, data.message]) : '';
425
+ if (turnFailureText) {
426
+ if (turnFailureText.includes('401') || turnFailureText.includes('Unauthorized')) nextState.authError = true;
427
+ nextState.turnFailures.push({ message: turnFailureText });
427
428
  }
428
429
 
429
430
  if (eventType === 'turn.completed' && data.usage && typeof data.usage === 'object') {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Shared human-readable rendering of structured error payloads (issue #2141).
3
+ *
4
+ * Agentic CLIs publish errors as *objects*, not strings. `@link-assistant/agent`
5
+ * 0.25.x emits `NamedError.toObject()`:
6
+ *
7
+ * {"type":"error","error":{"name":"RetryTimeoutExceededError","data":{"message":"…"}}}
8
+ *
9
+ * Every adapter that interpolated such a payload into a template literal
10
+ * (`Agent reported error: ${outputError.match}`) destroyed the diagnosis and
11
+ * published `AGENT execution failed with Agent reported error: [object Object]`
12
+ * to the GitHub issue, which is what issue #2141 reported. This module turns any
13
+ * of those shapes into text a human can act on, and is reused by every tool
14
+ * adapter so the defect cannot come back in one CLI at a time.
15
+ */
16
+
17
+ export const MAX_ERROR_TEXT_LENGTH = 2000;
18
+
19
+ /** Text that carries no diagnostic value even though it is a non-empty string. */
20
+ const PLACEHOLDER_ERROR_TEXTS = new Set(['[object object]', '[object error]', 'undefined', 'null', '{}', '[]']);
21
+
22
+ /**
23
+ * `[object Object]` (and friends) must never be published as a failure reason:
24
+ * it is the symptom this module exists to remove, so callers can assert on it.
25
+ */
26
+ export const isPlaceholderErrorText = value => {
27
+ if (typeof value !== 'string') return false;
28
+ return PLACEHOLDER_ERROR_TEXTS.has(value.trim().toLowerCase());
29
+ };
30
+
31
+ const truncateErrorText = (text, maxLength) => {
32
+ if (typeof text !== 'string') return '';
33
+ if (!Number.isFinite(maxLength) || maxLength <= 0) return text;
34
+ if (text.length <= maxLength) return text;
35
+ return `${text.slice(0, maxLength)}… (truncated)`;
36
+ };
37
+
38
+ const safeJsonStringify = value => {
39
+ const seen = new WeakSet();
40
+ try {
41
+ return JSON.stringify(value, (_key, entry) => {
42
+ if (entry && typeof entry === 'object') {
43
+ if (seen.has(entry)) return '[Circular]';
44
+ seen.add(entry);
45
+ }
46
+ if (typeof entry === 'bigint') return entry.toString();
47
+ if (typeof entry === 'function') return `[Function ${entry.name || 'anonymous'}]`;
48
+ return entry;
49
+ });
50
+ } catch {
51
+ return null;
52
+ }
53
+ };
54
+
55
+ const joinParts = parts => parts.filter(part => typeof part === 'string' && part.trim().length > 0).join(': ');
56
+
57
+ const stringifyValue = (value, depth) => {
58
+ if (value === null || value === undefined) return '';
59
+ if (typeof value === 'string') return value.trim();
60
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value);
61
+ if (typeof value === 'symbol' || typeof value === 'function') return String(value);
62
+
63
+ if (Array.isArray(value)) {
64
+ if (depth > 4) return safeJsonStringify(value) || String(value);
65
+ const rendered = value
66
+ .map(entry => stringifyValue(entry, depth + 1))
67
+ .filter(Boolean)
68
+ .join('; ');
69
+ return rendered || safeJsonStringify(value) || String(value);
70
+ }
71
+
72
+ if (typeof value !== 'object') return String(value);
73
+ if (depth > 4) return safeJsonStringify(value) || String(value);
74
+
75
+ if (value instanceof Error) {
76
+ const rendered = joinParts([value.name && value.name !== 'Error' ? value.name : '', value.message]);
77
+ return rendered || value.name || 'Error';
78
+ }
79
+
80
+ // `NamedError.toObject()` from @link-assistant/agent: {name, data:{message,…}}.
81
+ // The name alone ("RetryTimeoutExceededError") is already actionable, so keep
82
+ // it even when `data` carries no message.
83
+ const name = typeof value.name === 'string' && value.name.trim() ? value.name.trim() : null;
84
+
85
+ const nestedCandidates = [value.message, value.data, value.error, value.reason, value.cause, value.detail, value.details, value.description, value.hint, value.result, value.stderr];
86
+
87
+ for (const candidate of nestedCandidates) {
88
+ if (candidate === null || candidate === undefined) continue;
89
+ const rendered = stringifyValue(candidate, depth + 1);
90
+ if (!rendered || isPlaceholderErrorText(rendered)) continue;
91
+ // Avoid "Foo: Foo" when the nested value repeats the name.
92
+ if (name && rendered === name) return name;
93
+ return joinParts([name, rendered]);
94
+ }
95
+
96
+ if (name) return name;
97
+
98
+ const json = safeJsonStringify(value);
99
+ if (json && json !== '{}') return json;
100
+ return '';
101
+ };
102
+
103
+ /**
104
+ * Render any error payload (string, Error, NamedError object, nested envelope,
105
+ * array of the above) as a single human-readable line.
106
+ *
107
+ * @param {unknown} value - the payload as received from the tool stream.
108
+ * @param {object} [options]
109
+ * @param {number} [options.maxLength] - truncation budget for the rendered text.
110
+ * @param {string} [options.fallback] - returned when nothing readable is found.
111
+ * @returns {string} readable text, never `[object Object]`.
112
+ */
113
+ export const stringifyErrorValue = (value, { maxLength = MAX_ERROR_TEXT_LENGTH, fallback = '' } = {}) => {
114
+ const rendered = stringifyValue(value, 0);
115
+ if (!rendered || isPlaceholderErrorText(rendered)) return fallback;
116
+ return truncateErrorText(rendered, maxLength);
117
+ };
118
+
119
+ /**
120
+ * Pick the first readable rendering among several candidate payloads.
121
+ * Used where an adapter has to try `data.message`, then `data.error`, then the
122
+ * raw record text (the exact chain that produced `[object Object]` before).
123
+ */
124
+ export const firstErrorText = (candidates, { maxLength = MAX_ERROR_TEXT_LENGTH, fallback = '' } = {}) => {
125
+ for (const candidate of Array.isArray(candidates) ? candidates : [candidates]) {
126
+ const rendered = stringifyErrorValue(candidate, { maxLength });
127
+ if (rendered) return rendered;
128
+ }
129
+ return fallback;
130
+ };
@@ -17,6 +17,7 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
17
17
  import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
18
18
  const __geminiBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'gemini', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
19
19
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
20
+ import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
20
21
  import { defaultModels, geminiModels, isFormalAiModel } from './models/index.mjs';
21
22
  import { isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
22
23
  import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
@@ -210,7 +211,8 @@ const applyGeminiJsonEvent = (event, nextState, modelId = null) => {
210
211
  }
211
212
 
212
213
  if (data.error) {
213
- nextState.errorMessages.push(extractGeminiTextContent(data.error) || JSON.stringify(data.error));
214
+ // Issue #2141: prefer readable text over a raw JSON dump of the payload.
215
+ nextState.errorMessages.push(extractGeminiTextContent(data.error) || stringifyErrorValue(data.error, { fallback: JSON.stringify(data.error) }));
214
216
  } else if (type.toLowerCase().includes('error')) {
215
217
  nextState.errorMessages.push(text || JSON.stringify(data));
216
218
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { createCollapsible, createRawJsonSection, createRedactedRawJsonSection, escapeMarkdown, redactImageData, safeJsonStringify, truncateMiddle } from './interactive-mode.shared.lib.mjs';
4
4
  import { INTERACTIVE_SESSION_STARTED_MARKER } from './tool-comments.lib.mjs';
5
+ import { firstErrorText } from './error-text.lib.mjs'; // Issue #2141
5
6
 
6
7
  export const createCodexEventHandlers = ({ state, postComment, handleAssistantText, imageRenderer }) => {
7
8
  const handleCodexThreadStarted = async data => {
@@ -142,7 +143,9 @@ ${createRawJsonSection(data)}`);
142
143
  };
143
144
 
144
145
  const handleCodexError = async data => {
145
- const message = data.message || data.error?.message || 'Unknown Codex error';
146
+ // Issue #2141: `data.error` is often an object, so render it as text instead
147
+ // of letting `[object Object]` reach the GitHub comment.
148
+ const message = firstErrorText([data?.message, data?.error, data], { fallback: 'Unknown Codex error' });
146
149
  await postComment(`## ❌ Codex error
147
150
 
148
151
  ${createCollapsible('View error', escapeMarkdown(message), true)}
package/src/lib.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
3
  import { createCredentialStreamSanitizer, maskToken, sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
4
4
  import { recordLogBytes, resetLogGrowth } from './log-growth.lib.mjs'; // issue #2135: notice a session log that is running away
5
+ import { isPlaceholderErrorText } from './error-text.lib.mjs'; // issue #2141: never publish "[object Object]" as a reason
5
6
 
6
7
  export { maskToken };
7
8
 
@@ -762,6 +763,9 @@ export const isMeaningfulErrorText = value => {
762
763
  if (!value || typeof value !== 'string') return false;
763
764
  const collapsed = value.replace(/\s+/g, ' ').trim();
764
765
  if (!collapsed) return false;
766
+ // Issue #2141: "[object Object]" has letters, but it is the *absence* of an
767
+ // error message — a structured payload that was stringified by mistake.
768
+ if (isPlaceholderErrorText(collapsed)) return false;
765
769
  // Require at least one Unicode letter or number; pure punctuation/brackets
766
770
  // (e.g. "}", "{", "[]", ",") are stream fragments, not real errors.
767
771
  return /[\p{L}\p{N}]/u.test(collapsed);
@@ -820,6 +824,13 @@ export const extractToolErrorCore = ({ toolResult } = {}) => {
820
824
  // Issue #1941: reject stray fragments with no letters/digits (e.g. "}").
821
825
  if (!isMeaningfulErrorText(rawCore)) return null;
822
826
 
827
+ // Issue #2141: a core that embeds "[object Object]" (e.g. "Agent reported
828
+ // error: [object Object]") carries no diagnosis at all. Publishing the generic
829
+ // phrase is more honest than publishing a stringified object, and it keeps the
830
+ // symptom out of GitHub comments even if a new adapter forgets to render its
831
+ // payload as text.
832
+ if (rawCore.includes('[object Object]')) return null;
833
+
823
834
  // Collapse to a single clean line and strip noise.
824
835
  const core = rawCore.replace(/\s+/g, ' ').trim();
825
836
  return core || null;
package/src/qwen.lib.mjs CHANGED
@@ -27,6 +27,7 @@ import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toToken
27
27
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
28
28
  import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
29
29
  import { takeJsonRecords } from './json-stream.lib.mjs'; // Issue #2119
30
+ import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
30
31
 
31
32
  export const mapModelToId = model => qwenModels[model] || model;
32
33
 
@@ -41,18 +42,6 @@ const isQwenAuthError = output => {
41
42
  return text.includes('401') || text.includes('unauthorized') || text.includes('authentication') || text.includes('auth') || text.includes('login') || text.includes('api key') || text.includes('oauth free tier');
42
43
  };
43
44
 
44
- const stringifyErrorValue = value => {
45
- if (value === null || value === undefined) return '';
46
- if (typeof value === 'string') return value;
47
- if (typeof value?.message === 'string') return value.message;
48
- if (typeof value?.error?.message === 'string') return value.error.message;
49
- try {
50
- return JSON.stringify(value);
51
- } catch {
52
- return String(value);
53
- }
54
- };
55
-
56
45
  const getNestedValue = (object, pathParts) => {
57
46
  let cursor = object;
58
47
  for (const part of pathParts) {
@@ -12,6 +12,14 @@ const truncate = (value, maxLength = 2000) => {
12
12
 
13
13
  const fence = value => truncate(value || 'Unknown error').replaceAll('```', '` ` `');
14
14
 
15
+ /**
16
+ * Issue #2141: the failure comment was the *only* record of the run — the reason
17
+ * said "Agent reported error: [object Object]", `--attach-logs` was off, and the
18
+ * session log was never published, so the root cause could not be recovered
19
+ * afterwards. Say what is missing and how to make the next run diagnosable.
20
+ */
21
+ const buildLogLine = logAttachmentAttempted => (logAttachmentAttempted ? 'Log attachment was attempted but failed. Check the solver terminal log for the complete failure output.' : 'Logs were not attached because `--attach-logs` was not enabled, so this comment is the only surviving record of the failure. Rerun with `--attach-logs --verbose` to publish the full session log with the raw tool error records.');
22
+
15
23
  const isForkDivergenceFailure = reason => {
16
24
  const normalizedReason = String(reason || '').toLowerCase();
17
25
  return normalizedReason.includes('fork divergence') || (normalizedReason.includes('fork') && normalizedReason.includes('non-fast-forward')) || normalizedReason.includes('force-with-lease');
@@ -124,7 +132,7 @@ export function resolvePreExitFailureNotificationTarget({ code, globalState }) {
124
132
  export function buildPrePullRequestFailureComment({ reason, owner, repo, issueNumber, argv = {}, logAttachmentAttempted = false, failureActionSection = null }) {
125
133
  const tool = argv.tool || 'claude';
126
134
  const modelLine = argv.model ? `\n- **Requested model**: \`${argv.model}\`` : '';
127
- const logLine = logAttachmentAttempted ? 'Log attachment was attempted but failed. Check the solver terminal log for the complete failure output.' : 'Logs were not attached because `--attach-logs` was not enabled.';
135
+ const logLine = buildLogLine(logAttachmentAttempted);
128
136
  const actionSection = failureActionSection || buildPrePullRequestFailureActionSection(reason);
129
137
 
130
138
  return `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}
@@ -151,7 +159,7 @@ export function buildExistingPullRequestFailureComment({ reason, owner, repo, pr
151
159
  const tool = argv.tool || 'claude';
152
160
  const modelLine = argv.model ? `\n- **Requested model**: \`${argv.model}\`` : '';
153
161
  const issueLine = issueNumber ? `\n- **Linked issue**: #${issueNumber}` : '';
154
- const logLine = logAttachmentAttempted ? 'Log attachment was attempted but failed. Check the solver terminal log for the complete failure output.' : 'Logs were not attached because `--attach-logs` was not enabled.';
162
+ const logLine = buildLogLine(logAttachmentAttempted);
155
163
  const actionSection = failureActionSection || buildPrePullRequestFailureActionSection(reason);
156
164
 
157
165
  return `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}