2.5.3 #13

Merged
rune merged 39 commits from 2.5.3 into main 2026-08-31 15:07:03 +02:00
Owner

Lots of bug fixes and some new features. See release notes.

Lots of bug fixes and some new features. See release notes.
rune added 39 commits 2026-08-31 15:06:56 +02:00
macOS's built-in window-restore (Resume) can place the window at
coordinates for a display that's no longer connected, leaving it
visible-but-invisible with no way to recover short of quitting. Now
re-centers automatically on app activate/Dock reopen, plus a manual
"Reset Window Position" menu command as a guaranteed fallback.
attemptSaveCurrentConversation() (Save/Save As) and confirmDiscardIfNeeded's
no-unsaved-changes early return both left the on-disk draft_conversation.json
untouched, so a stale snapshot from before the save lingered and triggered
"Restore unsaved conversation?" on next launch even though everything had
already been saved with no changes since.
Lets the AI search Apple Mail, read a message, and save an attachment to
disk (e.g. to hand off to paperless_upload_document) — e.g. "find the
receipt from Elkjøp and add it to Paperless." Talks to Mail.app via
AppleScript/Apple Events rather than parsing its private on-disk store,
so no Full Disk Access or MIME parsing is needed; Mail's own attachment
save handles all decoding. Every mail_* tool call requires approval
(Deny/Allow Once/Allow for Session), mirroring the bash_execute and
Personal Data gate pattern.

Also fixes a pre-existing bug found while touching the adjacent
tool-activation condition: paperlessEnabled was missing from it, so
Paperless tools could fail to activate unless another integration was
also active.
appleScriptError carried the raw NSDictionary from NSAppleScript's error
out-param, which isn't Sendable and got flagged once the type crossed an
async boundary. Extract just the two fields actually used (errorNumber,
errorMessage) into plain Int/String instead.
Settings previously only surfaced an error message after a failed Test
Connection attempt — there was no way to trigger the actual macOS
Automation permission dialog or see live grant/deny status, unlike
Calendar/Contacts/Reminders/Location.

AEDeterminePermissionToAutomateTarget (askUserIfNeeded: false/true) turns
out to provide exactly that: a non-prompting status check and an explicit
prompt-and-wait call, mirroring EKEventStore.authorizationStatus(for:)/
requestFullAccessToEvents(). Mail's Settings row now uses the same
personalDataRow component as Calendar/Contacts — live status badge plus
a real "Request Access" button — instead of a one-off Test-Connection-only
UI. Test Connection stays as a secondary functional check.
requestAccess() was dispatched onto the background queue used for
AppleScript execution, on the assumption that a blocking call needed to
be off-main. That's backwards for AEDeterminePermissionToAutomateTarget's
consent dialog — like this app's existing NSAlert.runModal() calls, it
needs to run on the main thread/run loop to actually display. Off-main,
it silently returned without ever showing a prompt or changing state.

Also added Log.mail diagnostics on both the status-only check and the
prompting call so a future report of "still doesn't work" has an actual
status code to look at instead of starting from scratch.
Previous fix assumed being MainActor-isolated inside an async method was
equivalent to a classic synchronous AppKit call — it wasn't; Rune
confirmed live it still did nothing (no dialog, no state change, even
after a clean tccutil reset). AEDeterminePermissionToAutomateTarget is a
blocking, modal-dialog-presenting legacy API and needs to run from a
genuine DispatchQueue.main.async dispatch to correctly nest its own run
loop, not from inside a suspended Task continuation frame. Added extra
Log.mail checkpoints (call received / about to call AE / result) so the
next attempt has real data to diagnose from either way.
Rune's log capture proved the theory wrong: AEDeterminePermissionToAutomate-
Target(askUserIfNeeded: true) returned -1743 in ~9ms — far too fast for any
real dialog to have been shown and answered — and the very next passive
status check still reported -1744 (not yet determined), meaning the OS
never actually recorded a decision despite the "denied" return. That's the
pre-flight API misbehaving, not a threading issue (both previous fixes
addressed threading and neither helped).

requestAccess() now attempts a real, harmless Apple Event (listAccounts)
via the same runHandler path mail_list_accounts/testConnection() already
use — sending an actual Apple Event is the standard, proven mechanism for
triggering macOS's first-time Automation consent prompt, and this path is
already confirmed working (it's what correctly reported "not authorized"
in the first live test).
Confirmed via multiple live rounds with Rune this is a macOS 27 beta issue,
not a Confab bug — matches an already-documented pattern in this project
(Calendar/Contacts requestAccess failing identically). Every failure
returns in single-digit milliseconds, ruled out threading, wrong API,
build/signing, and notarization; even a fresh notarized build fails
identically, while Terminal->Mail (first-party) works with no prompt at
all, and the same-style bug already has a drafted Apple Feedback report
for a different permission category.

Kept the real Apple Event attempt as the primary path (the way this
should work once the OS bug is fixed), but now time it: a failure faster
than any human could plausibly answer a real dialog (default 300ms) is
classified as the platform bug rather than a genuine denial, and the UI
falls back to opening System Settings' Automation pane directly with an
explanatory note, instead of the button silently doing nothing.
Confirmed via live debugging this is a genuine OS bug (first-time
Automation consent grants never work on this beta), not fixable in-app.
Rather than ship a Settings section that can't currently work, added
MailTools.isHiddenPendingAppleFix (true for now) mirroring the existing
PersonalDataTools kill switch used for the same purpose during an earlier
Calendar/Contacts TCC bug — hides the Settings UI and forces mailEnabled
to false regardless of the persisted value, with no code deleted. Flip
back to false once a newer beta/RC is confirmed to fix it.
Root cause: on macOS, SecureField's bound value only commits when the
field loses focus (Return, click-away, tab switch) — not on every
keystroke like TextField. Every "Test Connection" button (Sync, Email,
Paperless, Anytype, Jarvis) gated its .disabled(...) on a *Configured
value fed by a SecureField-backed API key/token/password, so typing a
key straight into the field left the button looking permanently disabled
until something else forced a focus change.

Moved the "configured" check from the disabled condition into each test
function's action handler instead — by the time a click fires, the click
itself has already moved focus away and committed the field's value, so
the check now sees it correctly. Buttons stay clickable at all times
(gated only by their own isTesting spinner state) and show a clear
"Enter X first" message if config is actually incomplete.
The MCP tab had grown into one long scrolling list mixing seven unrelated
areas (File System, Bash Execution, Research Agents, External MCP
Servers, CLI Access, Personal Data, Mail), making it hard to find
anything. Split into a left sidebar (mirroring the existing top-bar
tabButton's blue-accent selected style, just as left-aligned icon+label
rows instead of icon-over-label) with each area now its own standalone
page.

The MCP tab renders outside the shared Settings ScrollView so its
content pane can scroll independently while the sidebar stays pinned —
nesting a plain ScrollView inside another one without an explicit height
just sizes to content rather than scrolling on its own. All 11 other
tabs are unaffected. Bumped the Settings window's min/ideal width
slightly to give the content pane room now that the sidebar takes some
of it on the MCP tab specifically.

Personal Data's and Mail's existing kill-switch guards
(PersonalDataTools/MailTools.isHiddenPendingAppleFix) now also hide
their sidebar rows entirely via MCPSubsection.visibleCases, not just
their content.
First Liquid Glass adoption in Confab (deployment target is already
macOS 26.2, so no #available gating needed anywhere). Converts the two
confirmed-safe, non-scrolling segmented selectors:

- Settings' top-level 12-tab bar (tabButton) and yesterday's new MCP-tab
  sidebar (mcpSidebarRow): selected-state Color.blue.opacity(0.1)
  backgrounds become tinted .glassEffect(), wrapped in
  GlassEffectContainer. Shipped without morphing (@Namespace/
  glassEffectID) for now, since at most one item is visible at a time in
  steady state — morphing is a stretch goal only if wanted after seeing
  this.
- ModelSelectorView's filter/category/favorites/sort chip row: flat
  Color.opacity() pill backgrounds become .glassEffect(), all wrapped in
  one GlassEffectContainer for a real multi-element merge demo (several
  chips can be active simultaneously here, unlike the single-selection
  tab bars).

Deliberately NOT touched in this phase: anything inside a ScrollView/
List (formSection cards, chat bubbles, sidebar rows) per Apple's own
no-glass-in-scroll-views guidance, and the two ChatView anti-patterns
(Header/FooterView's .ultraThinMaterial) which need a bigger safeAreaBar
restructuring, planned as Phase 2.
Rune caught this live: the selected MCP tab and "External MCP" sidebar
row rendered as solid opaque blue blocks completely hiding their
icon/label. Root cause: .glassEffect() was nested inside a
.background { } closure applied to a plain Color.clear placeholder,
which isn't how the API is meant to be used — it needs to wrap the real
content directly (as the skill's own examples show), not sit behind it
as an opaque background layer.

Also discovered the skill's documented isEnabled: parameter on
glassEffect(_:in:isEnabled:) isn't available on this SDK build
("extra argument in call") — worked around by branching the view
instead of using that parameter.

Since tabButton/mcpSidebarRow are actual Buttons, switched to the
more correct approach for buttons specifically: native
.buttonStyle(.glass)/.buttonStyle(.glassProminent) for the tab bar
(matching the skill's textbook button pattern) rather than hand-applying
.glassEffect() to custom content. mcpSidebarRow needed to stay on
direct .glassEffect() application (not button styles) since its row
needs to stretch full-width via a trailing Spacer(), which glass
button styles don't support — but applied directly to the real HStack
content this time, branched via if/else, not nested in .background.

Note for the next Rune check: native glass buttons add ~13pt of their
own internal padding, so manual padding was reduced/dropped on
tabButton's label — sizing may look different than before, adjust if
too large/small.
Rune caught this live too: content is now visible (last commit fixed
that), but the tinted glass (.regular.tint(.blue) etc.) rendered as a
solid, opaque, saturated color block instead of translucent frosted
glass — looked like a plain filled button, not Liquid Glass.

Dropped .tint() from all four Phase 1 conversions (tabButton,
mcpSidebarRow, and ModelSelectorView's filter/category/favorites/sort
chips) — the selection/active signal already comes from the icon/text
foreground color turning blue (or yellow, or the category color), which
was untouched by any of this. The glass itself is now always .regular
with no tint. tabButton's unselected state also switched from
.buttonStyle(.glass) to .buttonStyle(.plain) — restores the original
"only the selected tab shows any background" look, since with the tint
gone a plain .glass button style for every tab would visually flatten
the selected/unselected distinction back down to icon color alone.
Confab.entitlements was missing com.apple.security.automation.apple-events,
so tccd's hardened-runtime policy silently refused to even prompt for
Automation consent to Mail.app — confirmed via tccd's own log, the same
failure class as the earlier Calendar/Contacts entitlement bug. Unhides
the Mail integration (MailTools.isHiddenPendingAppleFix = false).
Live-verified: Request Access now grants successfully on macOS 27 beta 7.
Adds Mail Integration, Usage Analytics, CLI Access, and Paperless-NGX
sections; refreshes stale content (Contacts no longer hidden, External
MCP's HTTP transport, Folders, Bulk Actions, Apple Intelligence provider).
Localize mailAccessNote and AppleMailService's account-count string
(was a manual singular/plural literal, now uses inflect syntax). Also
correct the requestAccess() doc comment and the suspectedPlatformBug
fallback message, both of which still asserted "known macOS 27 beta
issue" — that diagnosis was wrong (see 6b4448d): the real cause was a
missing entitlement, already fixed. The instant-failure heuristic stays
as a defensive fallback, just no longer misattributed.
Three pre-existing spots used count == 1 ? "" : "s" instead of the
inflect syntax CLAUDE.md documents: GitSyncManualFixSheet's step()
helper (also switched String -> LocalizedStringKey, another documented
anti-pattern), PaperlessService.testConnection(), and
AnytypeMCPService.testConnection().
Root-caused two real issues Rune hit with Obsidian/Homepage external MCP
servers:

1. Toggling a server's enable switch silently wiped transportKind/env/
   url/bearerToken/headers back to stdio defaults (only id/name/command/
   args/isEnabled/timeout/createdAt were preserved) — almost certainly
   how Obsidian's config got corrupted into an empty-command stdio entry
   despite never being edited directly. Fixed via
   ExternalMCPServer.withEnabledToggled(), which flips only isEnabled.

2. npx (installed via Homebrew) was invisible to Confab because GUI apps
   only inherit launchd's minimal PATH, not the Terminal PATH. Tried
   spawning the user's login shell to ask for its real PATH — this
   caused two real hangs in one session (first an -ilc pipe deadlock,
   then a waitUntilExit()/CFRunLoop reentrancy issue even after fixing
   that) and was abandoned entirely in favor of LoginShellEnvironment:
   deterministic, subprocess-free directory probing (Homebrew, MacPorts,
   Volta, nvm's alias file) that can't hang by construction.

Also added:
- Edit capability for existing External MCP servers (previously only
  Add/Toggle/Delete) — the second thing Rune explicitly asked for, and
  the way to fix a corrupted entry like Obsidian's without deleting it.
- MCPClientError.commandNotFound: a stdio server's command is checked
  against PATH up front in StdioMCPTransport.prepare() and fails
  immediately with a clear reason instead of cycling through 3 rounds of
  crash/restart backoff (5s/15s/30s) for a permanently-missing binary.
- A "Get Node.js" button appears when this happens, opening a sheet with
  a copyable `brew install node`, a one-click install (via
  NodeInstallHelper, using the terminationHandler/readabilityHandler
  pattern already proven safe elsewhere in this file — deliberately not
  waitUntilExit()), or a nodejs.org link if Homebrew isn't present.
- ExternalMCPManager.retryClient(id:) to manually retry after fixing the
  underlying cause.
- Help book: new "Servers That Use npx" section, updated Server Status
  section, updated Settings blurb.

37 new/changed tests covering the toggle fix, PATH probing, the
commandNotFound fast-fail path, and missing-command detection — full
suite (374 tests) passes clean.
ExternalMCPManager.reconfigure() only started brand-new clients — a
server ID that already had a client (in ANY state, including .crashed)
was silently skipped even when its settings had just changed. Editing a
server in Settings and clicking Save persisted correctly but never
reached the live connection, which just kept running with its old
(often broken) config until the next app launch. Rune hit this directly
editing Obsidian's URL/token after the toggle-fields bug corrupted it.

Added ExternalMCPServer: Equatable so reconfigure can detect a changed
config for a still-enabled server and restart it fresh (extracted the
restart-attempt-reset logic already used by retryClient into a shared
restartFresh helper).
Real incident: pasting gethomepage.dev's example args array (JSON,
quotes/commas/brackets and all) into the plain "Arguments" field
produced tokens like "mcp-remote," with the comma baked in, which
crashed npx with EINVALIDTAGNAME on the literal package name
"mcp-remote,". The existing char-by-char tokenizer treats quote
characters as its own quoting mechanism and consumes them, so a JSON
array's per-item quotes never get stripped and commas outside them
become part of the token.

parseArguments now tries decoding a well-formed JSON array of strings
first (only when the whole trimmed input is bracket-wrapped valid
JSON), falling back to the original shell-style tokenizer otherwise —
so pasting a server's args straight from its JSON config now works.
Tooltip and help doc updated; a partial paste (e.g. missing the opening
bracket) still isn't valid JSON and falls back as before, documented as
a known limitation rather than silently guessed at.
Lets users with an existing Hugging Face account chat through HF's
Inference Providers router without signing up for another provider.
Model IDs are namespaced hf:org/model-name to avoid inferProvider's
OpenRouter "/" heuristic misrouting them; curated model list plus a
"Custom Model ID…" entry point stand in for HF's lack of a catalog/
pricing API.
GET /v1/models on HF's router is public and returns every currently
routable model with real per-provider pricing, context length, and
tool support — confirmed by reading the live response, which
contradicts the original plan's assumption that no such catalog
existed. Replaces the static 7-model list as the primary source (kept
as a fallback for fetch failures); pricing shown is the cheapest live
backend per model, since HF itself routes to the fastest one by
default and displaying a range isn't supported by ModelInfo.Pricing.
Some Hugging Face backends (e.g. Featherless AI) only route requests
if the user has added their own API key for that provider at HF's
settings — surfaced by Confab as a bare "not supported by any
provider you have enabled" error with no indication why. Enriches
that specific error with guidance, and adds the same note to the
Custom Model ID entry point. Also converted the alert-based Custom
Model ID dialog to a real sheet since SwiftUI alerts can't be resized
and it read as cramped.
First piece of the Apple Intelligence tool-calling work (see
/Users/rune/.claude/plans/apple-intelligence-tool-calling-plan.md).
AppleToolBridge.dynamicSchema converts Confab's existing MCP tool
JSON-Schema definitions into FoundationModels GenerationSchema values
at runtime via DynamicGenerationSchema, confirmed against the real
macOS 26.0+ API. One generic bridge covers every MCP tool — no
hand-written @Generable struct needed per tool. Verified against every
real tool definition MCPService.getToolSchemas() currently produces,
not just synthetic cases.

Pure logic, no session/provider wiring yet — that's the next step.
Second piece of the Apple Intelligence tool-calling work (see
/Users/rune/.claude/plans/apple-intelligence-tool-calling-plan.md).
DynamicMCPTool wraps one Confab tool definition using AppleToolBridge's
runtime schema, and dispatches into the same MCPService.executeTool(...)
every other provider's tool loop already uses — approval-gating for
bash/mail/personal-data is already handled inside executeTool itself,
confirmed by reading it directly, so no new gating logic was needed.

spawn_research_agents is out of scope for v1: it needs a live
AIProvider to recurse into, but AIProvider isn't Sendable and
FoundationModels.Tool requires Sendable conformance. Documented, not
silently dropped — executeTool's own existing guard reports this
clearly if invoked.

The real logic lives in a plain `execute(...)` static function, with
`call(arguments:)` itself a one-line pass-through — calling the
`@concurrent` Tool.call(arguments:) protocol requirement directly from
a test crashed the whole process (traced via a real .ips crash report,
not guessed). Also found and documented, not fixed: encodeResult's
`try? JSONSerialization.data(withJSONObject:)` can raise an
uncatchable Objective-C exception for invalid input — a real,
pre-existing risk shared by every provider's tool loop today, not
introduced here.
Third piece of the Apple Intelligence tool-calling work (see
/Users/rune/.claude/plans/apple-intelligence-tool-calling-plan.md).
AppleFoundationProvider now caches one live LanguageModelSession per
conversation instead of Phase 1's per-call rebuild, so real tool-call/
tool-output transcript entries survive between turns rather than being
discarded and replayed as a lossy text summary. Rebuilds only when the
conversation, tool set, or instructions actually change (tools and
instructions are init-time-only on a live session); a malformed tool
schema is skipped and logged rather than failing the whole session.
respondWithTools() gets-or-creates the session and calls .respond(to:)
once — FoundationModels runs any internal tool-call rounds itself.

Deliberately a new method on the concrete AppleFoundationProvider
type, not an AIProvider protocol extension, per the locked decision in
the plan. Extracted makeSession(for:)'s history-flattening into a
pure, tested flattenHistoryForInstructions(...) along the way, reused
by both the existing Phase 1 path and the new session-cache path.

10 new tests, including real LanguageModelSession construction/
identity checks for the cache's reuse-vs-rebuild logic. 424 tests
total, stable across two consecutive full runs.
Caught while building the ChatViewModel wiring (step 4): instructions
was both the cache-comparison key and the literal text sent to
LanguageModelSession. If per-turn conversation history were ever
folded into that same string (the natural next step once a caller
exists), the cache key would change on every single turn and the
session would rebuild every time — silently defeating the entire
point of a persistent session, with no test failure to catch it since
step 3's tests only ever passed a fixed instructions string.

Split into baseInstructions (stable across turns, the real cache key)
and priorMessagesForRebuildReplay (only consulted on an actual
rebuild, for best-effort continuity — same text-summary fallback
Phase 1's makeSession(for:) already uses). New test asserts the core
property directly: growing history across three turns reuses the same
session and never reports a rebuild.
Fifth piece of the Apple Intelligence tool-calling work (see
/Users/rune/.claude/plans/apple-intelligence-tool-calling-plan.md).
Once capabilities.tools flips to true for Apple On-Device (step 7,
not done yet), effectiveSystemPrompt's modelSupportsTools gate would
re-include the user's custom prompt and Agent Skills content — the
same two contributors that blew Phase 1's 4K context budget before
that gate existed (16,377/4,096 tokens on a trivial first message).
Measured the actual tool-usage guidance block instead of assuming it
also needed shortening: ~400 tokens, small enough to keep as-is, so no
separate condensed variant was needed — just keep excluding the two
genuinely large pieces specifically for Apple.

Dormant until step 7: modelSupportsTools is still false for Apple
today, so this has zero behavior change yet. No new tests —
effectiveSystemPrompt has no existing coverage (instance state, not
pure logic) and this change doesn't reduce what's already there.
Fourth and sixth pieces of the Apple Intelligence tool-calling work
(see /Users/rune/.claude/plans/apple-intelligence-tool-calling-plan.md).

generateAppleOnDeviceToolResponse() is a new, dedicated dispatch path
— routed to instead of the generic generateAIResponseWithTools() when
currentProvider == .appleOnDevice, since LanguageModelSession's
persistent, framework-driven tool loop doesn't fit the stateless,
manually-looped chatWithToolMessages contract every other provider
uses. Builds the tool list + system prompt once per turn, calls
AppleFoundationProvider.respondWithTools() exactly once, and renders
the result through the exact same showSystemMessage/
flushToolCallSummary/updateToolCallMessage path every other provider's
tool loop already uses, so the "🔧 Calling: …" UI looks identical
regardless of which provider is actually running.

New appleSessionKey gives Apple On-Device a stable per-conversation
identity even before a chat is ever saved (currentConversationId is
nil until first ⌘S) — an ephemeral UUID regenerated on New Chat and
Clear Chat, which also now explicitly resets the cached tool session
on both of those actions so a stale session is never reused for what
the user sees as a different conversation.

Dormant until step 7 flips capabilities.tools for Apple On-Device —
modelSupportTools is still false today, so this new dispatch branch
never actually fires yet. 425 tests, stable across two consecutive
full runs, zero regressions in the existing dispatch/reset paths this
touched.
Eighth piece of the Apple Intelligence tool-calling work (see
/Users/rune/.claude/plans/apple-intelligence-tool-calling-plan.md).
Reopening a saved conversation now restores the model's real
tool-call/tool-output history from a saved FoundationModels.Transcript
instead of falling back to Phase 1's lossy text-summary replay.

Found and confirmed against the real SDK before writing any code: a
live session's own .transcript property (needed to capture one to
save) is macOS 27.0+ only, while reconstructing a session FROM a saved
transcript works at 26.0+ — a real asymmetry, not a minor detail.
Checked with Rune before proceeding: build it gated behind
#available(macOS 27.0, *), same pattern this file already uses for
mapProviderError's LanguageModelError/GenerationError split. Inert on
macOS 26.x (falls back cleanly to the existing text-summary replay,
not broken), strengthens automatically as the OS matures.

AppleTranscriptService (new) persists one JSON file per conversation
under Application Support/oAI/apple_transcripts/, named directly by
UUID — no DB migration needed, since the filename is fully derivable
from the conversation's own id (unlike notes.md, which needs a stored
filename since it also embeds a human-readable display name).
Transcript itself is Codable at macOS 26.0+, so only the live-session
read is gated, not the encode/decode. Cleaned up on conversation
deletion (DatabaseService.deleteConversation, alongside the existing
notes.md cleanup) so a stale file never lingers for a deleted
conversation. Only ever saved for a real, already-saved conversation
(persistTranscriptId: UUID?, nil for a not-yet-saved chat) — an
unsaved chat's ephemeral session key never gets written, so there's no
unbounded orphan-file accumulation from chats that are never saved.

4 new tests (save/load round-trip with a real Transcript() value,
missing-file returns nil, delete is idempotent, deterministic path
derivation). 429 tests total, stable across two consecutive full
runs, and confirmed no leftover test-artifact files on disk after the
run.
Seventh and last piece of the Apple Intelligence tool-calling work
(see /Users/rune/.claude/plans/apple-intelligence-tool-calling-plan.md).
Done last, deliberately, per the plan — only after steps 1-6 and 8
were real, tested, and building on the real API rather than turning
this on speculatively. This is the switch that makes the whole
feature live: ChatViewModel's tool-active dispatch, the condensed
system prompt gating, and the persistent session/persistence machinery
were all already wired and dormant, waiting on this one flag.

Also updated chatWithToolMessages's throw message, which had gone
stale — it now correctly explains that this method is intentionally
unreachable for this provider (tool calling goes through the
dedicated respondWithTools path instead), rather than still claiming
"Phase 3" isn't built.

Checked every other capabilities.tools consumer in the codebase before
flipping — the two in ChatViewModel were already handled by steps 4/5;
everything else (ModelSelectorView, StatsView, ModelInfoView,
HeaderView) is cosmetic capability-badge display, correctly updating
to reflect the new capability with no functional risk.

429 tests, stable across two consecutive full runs.

This completes the plan's 8-step checklist:
1. AppleToolBridge — runtime MCP tool schema -> GenerationSchema
2. DynamicMCPTool — generic FoundationModels.Tool wrapper
3. Persistent per-conversation LanguageModelSession cache
4. Dedicated ChatViewModel dispatch path
5. On-device-safe system prompt (excludes custom prompt/skills)
6. Tool-call UI feedback wired through existing showSystemMessage/
   flushToolCallSummary/updateToolCallMessage
7. capabilities.tools = true (this commit)
8. Real Transcript-based session persistence, gated to macOS 27+
Rune caught this via a Release build (build.sh). The onWillStart
closure used [weak self] while its enclosing Task closure already
captures self strongly (matching generateAIResponseWithTools's
existing convention, which uses no weak self anywhere) — mixing
capture strength between nested closures over the same self is
exactly what Swift 6's new #ImplicitStrongCapture diagnostic flags.
Not a functional bug (self was already kept alive for the Task's
whole duration regardless), just inconsistent with this file's own
pattern. Removed the pointless weak capture.
Live-caught by Rune: a trivial one-tool request ("how many files in my
Downloads folder?") failed with "Provided 10,466 tokens, but the
maximum allowed is 8,192" — on the very first real test of tool
calling. Measured the real cause with Rune's actual live tool
configuration (via a throwaway, uncommitted diagnostic test, not
guessed): registering all 49 currently-enabled tools cost ~3,300
tokens of descriptions alone, before the system prompt, the user's
message, or any tool result even factored in. One External MCP server
(Obsidian, 16 tools) was 63% of that footprint by itself — third-party
tool descriptions are outside Confab's control and can be arbitrarily
verbose, unlike Confab's own deliberately-concise built-in tools.

Checked the fix approach with Rune before implementing (a real
product/scope decision, not something to guess): exclude External MCP
tools specifically for Apple On-Device (other providers' 100K+-token
windows absorb this fine) rather than building a full per-provider
tool-selection UI right now. Re-measured after the fix: 33 tools,
~1,212 tokens — comfortable headroom in a real conversation. Confab's
own built-in tools (files, bash, calendar, mail, contacts, Paperless,
Maps) are unaffected.

Also fixed while investigating: the real error dynamically reported
the actual 8,192-token limit (Apple's own error carries this), which
exposed that Confab's own hardcoded "4K context window" claims
(ModelInfo.contextLength, capabilities.maxContextLength, the model
description text, and a stale hardcoded "(4,096 tokens)" in the
macOS 26.x-only GenerationError fallback path) were wrong for the
current beta. Updated the verified ones to 8192; left the macOS
26.x-only error message without a specific number since whether that
runtime shares the same limit is genuinely unverified.

429 tests, clean.
JSONSerialization.data(withJSONObject:) looks like a normal throwing
API, but for a genuinely invalid top-level object (Double.nan/
.infinity anywhere in the object graph, or a non-bridgeable type) it
raises an Objective-C exception instead of a catchable Swift Error —
neither try nor try? protects against that, and the process crashes.
Logged as a priority roadmap item after being found while building
Apple Intelligence tool calling; fixing it now before release per
Rune's ask.

SafeJSONEncoding (new) checks JSONSerialization.isValidJSONObject
first — a plain, safe, non-throwing Bool check — before ever calling
the crash-prone encode path, returning nil instead of crashing for
invalid input. Verified the check itself can't be fooled (a standalone
script confirmed it correctly predicts NaN, Infinity, nested NaN, and
non-bridgeable-type cases, all without crashing) before wiring it in.

Applied to every call site that encodes data the app doesn't fully
control — tool results (ChatViewModel.generateAIResponseWithTools,
DynamicMCPTool.encodeResult, MCPService.serializeToolResult for
research sub-agents) and MessageRow's tool-call-detail pretty-printer.
Left outbound request-body construction in the provider files alone —
those dictionaries are built entirely from known Swift types the app
already controls (validated settings, string content), not from
tool/model/external-server output, so the same crash class isn't
realistically reachable there.

Also restored a test that previously had to be deliberately skipped
because it reproducibly crashed the whole test process on this exact
bug (documented at the time in feature_apple_intelligence_provider
memory) — now passes cleanly, directly confirming the fix rather than
just the new code compiling. 436 tests total, stable across two
consecutive full runs.
Ran the standard xcodebuild -exportLocalizations audit pass, which
caught 22 new Text() literals from this week's work (Hugging Face
provider UI, External MCP npx/Node.js install flow, Mail integration).
That tool only extracts Text() though — a follow-up manual sweep for
Button()/.help()/Label() literals specifically in the files touched
this session found 6 more genuinely new strings it missed (Custom
Model ID sheet's Add button, three HF pricing/model-picker tooltips,
the "Generate a token" link, the "Varies" pricing label).

All 28 translated into Norwegian Bokmål, Swedish, Danish, German, and
French, following this project's established conventions (informal
address, "token" kept as an English loanword, brand/product names
left untranslated). Verified no existing translations were lost in
the process (28 net new keys, 0 removed, 0 lost) and the app still
builds and the full test suite still passes.

A much larger, pre-existing gap was also found while auditing this —
showSystemMessage(...) (83 unique literal calls) and several other
Button()/.help() strings from earlier work have apparently never been
swept by any i18n pass in this project's history, since this project's
SWIFT_EMIT_LOC_STRINGS=NO setting means only regex-based Text()
extraction runs, not full compiler-based String Catalog extraction.
Deliberately out of scope for this release per discussion — logged
as a follow-up item, not fixed here.
Was still describing Phase 1 (no tools, 4K context) — stale since
today's Phase 2/3 work shipped. Now correctly documents the real 8K
context window and that tool calling works except for External MCP
servers.
rune merged commit ba16a2105b into main 2026-08-31 15:07:03 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
rune/oai-swift!13
No description provided.