@link-assistant/hive-mind 2.11.10 → 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,78 @@
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
+
42
+ ## 2.11.11
43
+
44
+ ### Patch Changes
45
+
46
+ - 36e7976: Keep stream provenance on mirrored agent output and make the Codex completion
47
+ gate explain itself (issue #2140).
48
+
49
+ A `solve --tool codex` run that had finished its work — PR updated, all 46
50
+ check-runs green, `turn.completed` received — was still failed by the completion
51
+ gate with `turn.started=3, turn.completed=1`. Replaying the real 96k-line log
52
+ through the parser shows the two extra `turn.started` records arrived on Codex's
53
+ **stderr**, inside an OTEL `codex.tool_result` dump of a command that merely read
54
+ a stored NDJSON log file from disk. This is the issue #2136 defect on a binary
55
+ released minutes before that fix shipped; current builds already gate correctly.
56
+ Three residual gaps remain, and this change closes them:
57
+
58
+ - `log()` accepted an `options.stream` hint and silently discarded it, so every
59
+ agent CLI's mirrored output — both streams, all five tools — was written as
60
+ `[INFO]` on our stdout. It is now tagged `[STDOUT]` / `[STDERR]`, matching the
61
+ tags the stdio interceptor already uses, and mirrored stderr goes to our
62
+ stderr so piping stdout yields only what the child wrote there. An explicit
63
+ `level` still wins, and callers that pass no stream are unchanged.
64
+ - `codex exec` starts exactly one thread, so a `thread.started` on the protocol
65
+ stream announcing a different `thread_id` is proof of echoed output. Those ids
66
+ are now collected and reported (`🧬 Foreign thread IDs seen on the codex
67
+ protocol stream`).
68
+ - The completion-failure reason carried counts and nothing else, which made a
69
+ false positive impossible to refute from the posted comment. It now also
70
+ states the ordered turn lifecycle, how many `turn.started` records were
71
+ discarded as echoed telemetry, and any foreign thread id seen.
72
+
73
+ No gate outcome changes: a genuinely truncated turn still fails, and a completed
74
+ run with echoed events still passes.
75
+
3
76
  ## 2.11.10
4
77
 
5
78
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.10",
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
@@ -1009,7 +1010,7 @@ export const executeClaudeCommand = async params => {
1009
1010
  }
1010
1011
  // Not JSON or parsing failed, output as-is if it's not empty
1011
1012
  if (line.trim() && !line.includes('node:internal')) {
1012
- await log(line, { stream: 'raw' });
1013
+ await log(line, { stream: 'stdout' });
1013
1014
  lastMessage = line;
1014
1015
  // Issue #1015: Detect terms acceptance prompt (non-JSON "[ACTION REQUIRED]..." message)
1015
1016
  const termsAcceptancePattern = /\[ACTION REQUIRED\].*terms|must run.*claude.*review.*terms/i;
@@ -1080,7 +1081,7 @@ export const executeClaudeCommand = async params => {
1080
1081
  }
1081
1082
  if (progressMonitor) await progressMonitor.processStreamEvent(data, true).catch(e => log(`⚠️ Progress: ${e.message}`, { verbose: true }));
1082
1083
  } catch {
1083
- if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: 'raw' });
1084
+ if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: 'stdout' });
1084
1085
  }
1085
1086
  }
1086
1087
  if (startupTimeoutId) {
@@ -269,9 +269,28 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
269
269
  addDiskEvidence('result-summary', codexJsonState?.resultSummary);
270
270
  const diskPressureDetected = diskEvidence.length > 0;
271
271
 
272
+ // Issue #2140: the counts alone made the #2136 false positive unfalsifiable —
273
+ // "turn.started=3, turn.completed=1" was posted to the PR with nothing to say
274
+ // whether those starts were codex's own. Carry the evidence that decides it:
275
+ // the ordered lifecycle, what was discarded as echoed telemetry, and any
276
+ // foreign thread id that reached the protocol stream.
277
+ const turnLifecycle = Array.isArray(codexJsonState?.turnLifecycle) ? codexJsonState.turnLifecycle : [];
278
+ const telemetryEventCounts = codexJsonState?.telemetryEventCounts || {};
279
+ const foreignThreadIds = codexJsonState?.foreignThreadIds || [];
280
+ const echoedTurnStarts = telemetryEventCounts['turn.started'] || 0;
281
+
272
282
  const reasons = [];
273
283
  if (incompleteSession) {
274
284
  reasons.push(`Codex session ended without completing its turn (turn.started=${turnStarted}, turn.completed=${turnCompleted}, turn.failed=${turnFailed}); the process exited 0 but was cut off mid-turn.`);
285
+ if (turnLifecycle.length > 0) {
286
+ reasons.push(`Turn lifecycle in order: ${turnLifecycle.join(' → ')} — the stream ends on a start, so the last turn never finished.`);
287
+ }
288
+ if (echoedTurnStarts > 0 || foreignThreadIds.length > 0) {
289
+ const echoParts = [];
290
+ if (echoedTurnStarts > 0) echoParts.push(`${echoedTurnStarts} echoed turn.started on codex stderr (excluded from the counts above)`);
291
+ if (foreignThreadIds.length > 0) echoParts.push(`foreign thread id(s) on the protocol stream: ${foreignThreadIds.join(', ')}`);
292
+ reasons.push(`Echo diagnostics (issues #2136/#2140): ${echoParts.join('; ')}.`);
293
+ }
275
294
  if (diskPressureDetected) {
276
295
  reasons.push(`Disk-exhaustion signals were present in ${diskEvidence.length} location(s) (e.g. "No space left on device") — the likely cause of the interrupted session.`);
277
296
  }
@@ -285,6 +304,9 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
285
304
  turnStarted,
286
305
  turnCompleted,
287
306
  turnFailed,
307
+ turnLifecycle,
308
+ echoedTurnStarts,
309
+ foreignThreadIds,
288
310
  reasons,
289
311
  };
290
312
  };
@@ -305,6 +327,9 @@ export const reportCodexCompletionFailure = async ({ completionHealth, log, getR
305
327
  await log(` • ${reason}`, { level: 'error' });
306
328
  }
307
329
  await log(` 📊 turn.started=${completionHealth.turnStarted}, turn.completed=${completionHealth.turnCompleted}, turn.failed=${completionHealth.turnFailed}`, { verbose: true });
330
+ if (completionHealth.turnLifecycle?.length) {
331
+ await log(` 🔁 turn lifecycle: ${completionHealth.turnLifecycle.join(' → ')}`, { verbose: true });
332
+ }
308
333
  if (completionHealth.diskPressureDetected) {
309
334
  await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
310
335
  for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
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
@@ -43,52 +44,11 @@ import { applyCodexCapabilityEnv, runCodexCapabilityPreflight } from './codex-ca
43
44
  import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
44
45
  import Decimal from 'decimal.js-light';
45
46
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
47
+ import { CODEX_CACHE_READ_USAGE_PATHS, CODEX_CACHE_WRITE_USAGE_PATHS, CODEX_MODEL_DIAGNOSTIC_PATHS, CODEX_REASONING_USAGE_PATHS, CODEX_USAGE_FIELD_NAMES, createCodexTokenFieldAvailability, getFirstObservedNumber, hasAnyObservedPath, hasOwnPath } from './codex.usage-fields.lib.mjs';
46
48
 
47
- const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'reasoning_output_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
48
49
  const CODEX_LONG_CONTEXT_PRICE_THRESHOLD = 272000;
49
50
  const CODEX_COMPACT_API_ENDPOINT = '/responses/compact';
50
51
  const getCodexExecEnv = (verbose = false) => (verbose ? { ...process.env, RUST_LOG: 'debug' } : { ...process.env });
51
- const CODEX_MODEL_DIAGNOSTIC_PATHS = [
52
- ['model', data => data?.model],
53
- ['model_name', data => data?.model_name],
54
- ['from_model', data => data?.from_model],
55
- ['to_model', data => data?.to_model],
56
- ['message.model', data => data?.message?.model],
57
- ];
58
-
59
- const createCodexTokenFieldAvailability = () => ({
60
- inputTokens: false,
61
- outputTokens: false,
62
- reasoningTokens: false,
63
- cacheReadTokens: false,
64
- cacheWriteTokens: false,
65
- });
66
-
67
- const hasOwnPath = (object, pathName) => {
68
- let cursor = object;
69
- for (const part of pathName.split('.')) {
70
- if (!cursor || typeof cursor !== 'object' || !Object.hasOwn(cursor, part)) return false;
71
- cursor = cursor[part];
72
- }
73
- return true;
74
- };
75
-
76
- const getPathValue = (object, pathName) => pathName.split('.').reduce((cursor, part) => cursor?.[part], object);
77
-
78
- const getFirstObservedNumber = (object, pathNames) => {
79
- for (const pathName of pathNames) {
80
- if (!hasOwnPath(object, pathName)) continue;
81
- const value = getPathValue(object, pathName);
82
- return Number.isFinite(value) ? value : 0;
83
- }
84
- return 0;
85
- };
86
-
87
- const hasAnyObservedPath = (object, pathNames) => pathNames.some(pathName => hasOwnPath(object, pathName));
88
-
89
- const CODEX_CACHE_READ_USAGE_PATHS = ['cached_input_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens'];
90
- const CODEX_CACHE_WRITE_USAGE_PATHS = ['cache_write_tokens', 'cache_creation_input_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens'];
91
- const CODEX_REASONING_USAGE_PATHS = ['reasoning_tokens', 'reasoning_output_tokens', 'output_tokens_details.reasoning_tokens'];
92
52
 
93
53
  const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
94
54
 
@@ -380,6 +340,12 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
380
340
  // completion gate can ask "did the last turn finish?" instead of comparing
381
341
  // counts that an echoed `turn.started` can skew.
382
342
  turnLifecycle: state.turnLifecycle || [],
343
+ // Issue #2140: `thread.started` records seen on the protocol stream that
344
+ // announce a thread id other than this session's. Codex only starts one
345
+ // thread per `codex exec`, so a second id is proof that something echoed
346
+ // another agent's protocol into ours — the one turn event that carries an
347
+ // identity we can check. Diagnostics only; the gate stays order-based.
348
+ foreignThreadIds: state.foreignThreadIds || [],
383
349
  };
384
350
 
385
351
  nextState.tokenUsage.tokenFieldAvailability ||= createCodexTokenFieldAvailability();
@@ -432,6 +398,11 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
432
398
 
433
399
  if (eventType === 'thread.started' && typeof data.thread_id === 'string' && !nextState.sessionId) {
434
400
  nextState.sessionId = data.thread_id;
401
+ } else if (eventType === 'thread.started' && typeof data.thread_id === 'string' && data.thread_id !== nextState.sessionId) {
402
+ // Issue #2140: a foreign thread id on the protocol stream is echoed
403
+ // output, not a second codex session. Record it once so a run that ends
404
+ // up disputed can be settled from the log alone.
405
+ if (!nextState.foreignThreadIds.includes(data.thread_id)) nextState.foreignThreadIds.push(data.thread_id);
435
406
  } else if (!nextState.sessionId && typeof data.session_id === 'string') {
436
407
  nextState.sessionId = data.session_id;
437
408
  }
@@ -440,20 +411,20 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
440
411
  if (typeof getter(data) === 'string') observedModelPaths.add(pathName);
441
412
  }
442
413
 
443
- if (eventType === 'error' && typeof data.message === 'string' && (data.message.includes('401 Unauthorized') || data.message.includes('401') || data.message.includes('Unauthorized'))) {
444
- nextState.authError = true;
445
- }
446
-
447
- if (eventType === 'error' && typeof data.message === 'string') {
448
- nextState.streamErrors.push({ message: data.message });
449
- }
450
-
451
- 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'))) {
452
- 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 });
453
422
  }
454
423
 
455
- if (eventType === 'turn.failed' && typeof data.error?.message === 'string') {
456
- 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 });
457
428
  }
458
429
 
459
430
  if (eventType === 'turn.completed' && data.usage && typeof data.usage === 'object') {
@@ -1030,7 +1001,7 @@ export const executeCodexCommand = async params => {
1030
1001
  if (chunk.type === 'stdout') {
1031
1002
  const raw = chunk.data.toString();
1032
1003
  if (argv.verbose) {
1033
- await log(raw);
1004
+ await log(raw, { stream: 'stdout' });
1034
1005
  }
1035
1006
  lastMessage = raw;
1036
1007
  const output = codexStdoutLines.write(raw);
@@ -97,6 +97,13 @@ export const buildCodexRunDiagnostics = ({ state = {}, exitCode = null, mappedMo
97
97
  }
98
98
  if (state.turnLifecycle?.length > 0) push(`🔁 Codex turn lifecycle: ${state.turnLifecycle.join(' → ')}`);
99
99
 
100
+ // Issue #2140: codex starts exactly one thread per `codex exec`, so any other
101
+ // thread id on the protocol stream is echoed output that leaked past the
102
+ // stream separation above. Always worth saying out loud.
103
+ if (state.foreignThreadIds?.length > 0) {
104
+ push(`🧬 Foreign thread IDs seen on the codex protocol stream (echoed, not codex sessions): ${state.foreignThreadIds.join(', ')}`, { level: 'warning', verbose: true });
105
+ }
106
+
100
107
  const usage = state.tokenUsage || {};
101
108
  if (usage.stepCount > 0) {
102
109
  push(`📈 Codex usage from turn.completed: ${usage.inputTokens.toLocaleString()} input, ${usage.cacheReadTokens.toLocaleString()} cache read, ${usage.outputTokens.toLocaleString()} output across ${usage.stepCount} turn(s)`);
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Usage-field vocabulary and JSON-path helpers for the `codex exec --json`
3
+ * parser.
4
+ *
5
+ * Split out of codex.lib.mjs to keep that file inside the max-lines budget
6
+ * (issues #1730 / #1990 / #2140). Everything here is pure data plus pure
7
+ * lookups: codex has renamed and re-nested its usage fields several times
8
+ * across releases, so the parser reads whichever spelling is *present* rather
9
+ * than assuming one shape, and reports what it actually observed.
10
+ */
11
+
12
+ /** Every usage field name we know codex has used, for observability reporting. */
13
+ export const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'reasoning_output_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
14
+
15
+ /** Places a codex event has been seen to name a model, in preference order. */
16
+ export const CODEX_MODEL_DIAGNOSTIC_PATHS = [
17
+ ['model', data => data?.model],
18
+ ['model_name', data => data?.model_name],
19
+ ['from_model', data => data?.from_model],
20
+ ['to_model', data => data?.to_model],
21
+ ['message.model', data => data?.message?.model],
22
+ ];
23
+
24
+ export const CODEX_CACHE_READ_USAGE_PATHS = ['cached_input_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens'];
25
+ export const CODEX_CACHE_WRITE_USAGE_PATHS = ['cache_write_tokens', 'cache_creation_input_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens'];
26
+ export const CODEX_REASONING_USAGE_PATHS = ['reasoning_tokens', 'reasoning_output_tokens', 'output_tokens_details.reasoning_tokens'];
27
+
28
+ /** Which token kinds this run has actually seen codex report. */
29
+ export const createCodexTokenFieldAvailability = () => ({
30
+ inputTokens: false,
31
+ outputTokens: false,
32
+ reasoningTokens: false,
33
+ cacheReadTokens: false,
34
+ cacheWriteTokens: false,
35
+ });
36
+
37
+ /** Own-property check along a dotted path — absent ≠ present-and-zero. */
38
+ export const hasOwnPath = (object, pathName) => {
39
+ let cursor = object;
40
+ for (const part of pathName.split('.')) {
41
+ if (!cursor || typeof cursor !== 'object' || !Object.hasOwn(cursor, part)) return false;
42
+ cursor = cursor[part];
43
+ }
44
+ return true;
45
+ };
46
+
47
+ export const getPathValue = (object, pathName) => pathName.split('.').reduce((cursor, part) => cursor?.[part], object);
48
+
49
+ /** First path that is actually present wins; a non-finite value counts as 0. */
50
+ export const getFirstObservedNumber = (object, pathNames) => {
51
+ for (const pathName of pathNames) {
52
+ if (!hasOwnPath(object, pathName)) continue;
53
+ const value = getPathValue(object, pathName);
54
+ return Number.isFinite(value) ? value : 0;
55
+ }
56
+ return 0;
57
+ };
58
+
59
+ export const hasAnyObservedPath = (object, pathNames) => pathNames.some(pathName => hasOwnPath(object, pathName));
@@ -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
  }
@@ -462,7 +464,7 @@ export const executeGeminiCommand = async params => {
462
464
  for await (const chunk of execCommand.stream()) {
463
465
  if (chunk.type === 'stdout') {
464
466
  const output = chunk.data.toString();
465
- await log(output);
467
+ await log(output, { stream: 'stdout' });
466
468
  allOutput += output;
467
469
  geminiJsonState = parseGeminiJsonOutput(output, geminiJsonState, mappedModel);
468
470
  if (geminiJsonState.sessionId) {
@@ -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
 
@@ -95,10 +96,14 @@ export const getAbsoluteLogPath = async () => {
95
96
  * @param {Object} options - Logging options
96
97
  * @param {string} [options.level='info'] - Log level (info, warn, error)
97
98
  * @param {boolean} [options.verbose=false] - Whether this is a verbose log
99
+ * @param {string} [options.stream] - Provenance of the message when it is raw
100
+ * output mirrored from a child process: 'stdout' or 'stderr'. Tags the log
101
+ * file lines [STDOUT]/[STDERR] (matching the process.stdout/stderr
102
+ * interceptor below) and routes the console write to the same stream.
98
103
  * @returns {Promise<void>}
99
104
  */
100
105
  export const log = async (message, options = {}) => {
101
- const { level = 'info', verbose = false } = options;
106
+ const { level = 'info', verbose = false, stream = null } = options;
102
107
 
103
108
  // Skip verbose logs unless --verbose is enabled
104
109
  if (verbose && !global.verboseMode) {
@@ -107,12 +112,20 @@ export const log = async (message, options = {}) => {
107
112
 
108
113
  const sanitizedMessage = sanitizeCredentialText(message);
109
114
 
115
+ // Issue #2140: mirrored child output must stay attributable to the stream it
116
+ // came from. Both Codex streams used to be written as plain [INFO], so a run
117
+ // log could not answer "did Codex emit this protocol line, or did its stderr
118
+ // merely echo one?" — the exact question a false completion failure hinges on.
119
+ // An explicit level still wins, so warnings/errors keep their own tag.
120
+ const mirroredStream = stream === 'stdout' || stream === 'stderr' ? stream : null;
121
+ const tag = mirroredStream && level === 'info' ? mirroredStream.toUpperCase() : level.toUpperCase();
122
+
110
123
  // Write to file if log file is set
111
124
  // Issue #1572: Handle multi-line messages by timestamping each line,
112
125
  // so continuation lines don't appear without timestamps in the log file
113
126
  if (logFile) {
114
127
  const timestamp = new Date().toISOString();
115
- const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
128
+ const prefix = `[${timestamp}] [${tag}]`;
116
129
  const lines = sanitizedMessage.split('\n');
117
130
  const logMessage = lines.map(line => `${prefix} ${line}`).join('\n');
118
131
  try {
@@ -146,7 +159,10 @@ export const log = async (message, options = {}) => {
146
159
  break;
147
160
  case 'info':
148
161
  default:
149
- console.log(sanitizedMessage);
162
+ // Mirrored child stderr goes to our stderr, so piping stdout to a
163
+ // consumer keeps yielding only what the child wrote to stdout.
164
+ if (mirroredStream === 'stderr') console.error(sanitizedMessage);
165
+ else console.log(sanitizedMessage);
150
166
  break;
151
167
  }
152
168
  } finally {
@@ -747,6 +763,9 @@ export const isMeaningfulErrorText = value => {
747
763
  if (!value || typeof value !== 'string') return false;
748
764
  const collapsed = value.replace(/\s+/g, ' ').trim();
749
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;
750
769
  // Require at least one Unicode letter or number; pure punctuation/brackets
751
770
  // (e.g. "}", "{", "[]", ",") are stream fragments, not real errors.
752
771
  return /[\p{L}\p{N}]/u.test(collapsed);
@@ -805,6 +824,13 @@ export const extractToolErrorCore = ({ toolResult } = {}) => {
805
824
  // Issue #1941: reject stray fragments with no letters/digits (e.g. "}").
806
825
  if (!isMeaningfulErrorText(rawCore)) return null;
807
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
+
808
834
  // Collapse to a single clean line and strip noise.
809
835
  const core = rawCore.replace(/\s+/g, ' ').trim();
810
836
  return core || null;
@@ -394,7 +394,7 @@ export const executeOpenCodeCommand = async params => {
394
394
  for await (const chunk of execCommand.stream()) {
395
395
  if (chunk.type === 'stdout') {
396
396
  const output = chunk.data.toString();
397
- await log(output);
397
+ await log(output, { stream: 'stdout' });
398
398
  lastMessage = output;
399
399
  allOutput += output;
400
400
 
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) {
@@ -566,7 +555,7 @@ export const executeQwenCommand = async params => {
566
555
  for await (const chunk of execCommand.stream()) {
567
556
  if (chunk.type === 'stdout') {
568
557
  const output = chunk.data.toString();
569
- await log(output);
558
+ await log(output, { stream: 'stdout' });
570
559
  allOutput += output;
571
560
  qwenState = parseQwenStreamJsonOutput(output, qwenState);
572
561
  }
@@ -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}