commit 4aaff5e46a69b92c05c77cda7f9f2584df18a7a7
Author:     William Cory <willcory10@gmail.com>
AuthorDate: Wed Sep 30 13:53:53 2026 -0700
Commit:     William Cory <willcory10@gmail.com>
CommitDate: Wed Sep 30 13:56:33 2026 -0700

    🐛 fix(tui): keep Error.message when a stack omits it (#3248)
    
    Log.describe wrote Error.stack whenever present, but a ZodError's stack under
    Bun is just "ZodError", so the diagnostic log lost the parser's message. When
    the stack does not contain the message, the record now leads with
    "name: message" and keeps the stack's frames; causes, cycles and redaction
    are unchanged.
    
    Dispatched-By: dz-smithers-3065
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/apps/tui/src/log.ts b/apps/tui/src/log.ts
index 13e23cdfc0..c41e3b2e5d 100644
--- a/apps/tui/src/log.ts
+++ b/apps/tui/src/log.ts
@@ -30,13 +30,21 @@ const fields = (value: object): string => {
     return String(value)
   }
 }
-/** Error.stack omits Error.cause; retain its chain without looping on cycles. */
+/**
+ * Error.stack omits Error.cause, and some errors' stacks omit their message (a
+ * ZodError's is `ZodError`): retain both, the chain without looping on cycles.
+ */
 const describe = (error: unknown, seen = new Set<unknown>()): string => {
   if (seen.has(error)) return "[circular cause]"
   if (seen.size >= 16) return "[cause chain truncated]"
   seen.add(error)
   if (!(error instanceof Error)) return typeof error === "object" && error !== null ? fields(error) : String(error)
-  const own = error.stack ?? error.message
+  const stack = error.stack
+  const own = stack === undefined
+    ? `${error.name}: ${error.message}`
+    : stack.includes(error.message)
+    ? stack
+    : [`${error.name}: ${error.message}`, ...stack.split("\n").slice(1)].join("\n")
   return error.cause === undefined ? own : `${own}\nCaused by: ${describe(error.cause, seen)}`
 }
 /** Record the full cause, redacting credential-shaped text before writing. */
diff --git a/apps/tui/test/log.test.ts b/apps/tui/test/log.test.ts
index 8fef33bfec..02cf24448c 100644
--- a/apps/tui/test/log.test.ts
+++ b/apps/tui/test/log.test.ts
@@ -1,3 +1,4 @@
+import { MythicalStackSchema } from "@smthrs/rpc/Mythical"
 import { expect, it } from "bun:test"
 import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"
 import { tmpdir } from "node:os"
@@ -96,3 +97,37 @@ it("redacts quoted multi-word, inspect-split and any-scheme header credentials i
     rmSync(root, { recursive: true, force: true })
   }
 })
+
+it("keeps an error's message when its stack omits it, as a parser error's does", () => {
+  const previous = process.env.SMITHERS_TUI_SESSION_DIR
+  const root = mkdtempSync(join(tmpdir(), "tui-log-message-"))
+  process.env.SMITHERS_TUI_SESSION_DIR = root
+  try {
+    let parser: unknown
+    try {
+      MythicalStackSchema.parse({})
+    } catch (error) {
+      parser = error
+    }
+    const bare = new Error(`token ghp_${"y".repeat(36)} rejected`)
+    bare.stack = "Error\n    at read (cloud.ts:1:1)"
+    const wrapped = new Error("stack unreadable", { cause: bare })
+    const ordinary = new Error("disk full")
+    Log.write("factory.load", parser)
+    Log.write("factory.todo", wrapped)
+    Log.write("factory.retry", ordinary)
+    const saved = readFileSync(Log.path(), "utf8")
+    const [parsed, nested, plain] = saved.trim().split("\n").map((line) => JSON.parse(line).detail as string)
+    expect(parsed).toStartWith("ZodError: ")
+    expect(parsed).toContain("invalid_type")
+    expect(nested).toContain("Caused by: Error: token [REDACTED")
+    expect(nested).toContain("at read (cloud.ts:1:1)")
+    expect(saved.includes("y".repeat(36))).toBe(false)
+    // A stack that already names the message is written once, as it was.
+    expect(plain).toBe(ordinary.stack!)
+  } finally {
+    if (previous === undefined) delete process.env.SMITHERS_TUI_SESSION_DIR
+    else process.env.SMITHERS_TUI_SESSION_DIR = previous
+    rmSync(root, { recursive: true, force: true })
+  }
+})
