Add real Transcript-based session persistence (step 8)

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.
This commit is contained in:
2026-08-28 12:51:28 +02:00
parent c7681d227f
commit 8006f22d69
5 changed files with 178 additions and 6 deletions
+32 -6
View File
@@ -248,13 +248,24 @@ final class AppleFoundationProvider: AIProvider {
}
}
let fullInstructions = priorMessagesForRebuildReplay.isEmpty
? baseInstructions
: Self.flattenHistoryForInstructions(systemPrompt: baseInstructions, priorMessages: priorMessagesForRebuildReplay)
// A saved transcript (see AppleTranscriptService) carries the model's *real* prior tool-call
// history restore from it when one exists, instead of falling back to the lossy
// text-summary replay below. Only ever present for a real, previously-saved conversation;
// an unsaved chat's ephemeral session key never has a file on disk, so this is always a
// harmless no-op (nil) for that case.
let savedTranscript = UUID(uuidString: conversationId).flatMap { AppleTranscriptService.shared.load(for: $0) }
let session: LanguageModelSession = fullInstructions.isEmpty
? LanguageModelSession(tools: dynamicTools)
: LanguageModelSession(tools: dynamicTools, instructions: fullInstructions)
let session: LanguageModelSession
if let savedTranscript {
session = LanguageModelSession(tools: dynamicTools, transcript: savedTranscript)
} else {
let fullInstructions = priorMessagesForRebuildReplay.isEmpty
? baseInstructions
: Self.flattenHistoryForInstructions(systemPrompt: baseInstructions, priorMessages: priorMessagesForRebuildReplay)
session = fullInstructions.isEmpty
? LanguageModelSession(tools: dynamicTools)
: LanguageModelSession(tools: dynamicTools, instructions: fullInstructions)
}
cachedToolSession = ToolSessionCacheEntry(conversationId: conversationId, toolNames: toolNames, baseInstructions: baseInstructions, session: session)
return (session, rebuildingSameConversation)
@@ -271,8 +282,18 @@ final class AppleFoundationProvider: AIProvider {
/// it, then call `.respond(to:)` exactly once. FoundationModels handles any internal tool-call
/// rounds itself Confab doesn't manually loop the way it does for every other provider's
/// `chatWithToolMessages`.
///
/// `persistTranscriptId` is the conversation's *real* database id pass nil for a not-yet-saved
/// chat (`Conversation.id` doesn't exist yet, so there's nowhere durable to restore from later
/// anyway). When non-nil and the running OS is macOS 27.0+, the session's transcript is saved
/// after a successful turn so reopening this conversation restores real tool-call history see
/// `AppleTranscriptService`. Reading a *live* session's `.transcript` is macOS 27.0+ only (an
/// asymmetry with reconstructing FROM a saved one, which works at 26.0+ confirmed by reading
/// the SDK directly); on macOS 26.x this is silently a no-op, matching how this file already
/// splits behavior by OS version for `mapProviderError`.
func respondWithTools(
conversationId: String,
persistTranscriptId: UUID?,
tools: [Confab.Tool],
baseInstructions: String,
priorMessagesForRebuildReplay: [Message],
@@ -291,6 +312,11 @@ final class AppleFoundationProvider: AIProvider {
do {
let result: LanguageModelSession.Response<String> = try await session.respond(to: userMessage)
if #available(macOS 27.0, *), let persistTranscriptId {
AppleTranscriptService.shared.save(session.transcript, for: persistTranscriptId)
}
let response = ChatResponse(
id: UUID().uuidString,
model: "apple-on-device",
+85
View File
@@ -0,0 +1,85 @@
//
// AppleTranscriptService.swift
// Confab
//
// Persists a saved conversation's Apple On-Device tool-calling session state
// (FoundationModels.Transcript) as a per-conversation JSON file under
// Application Support/oAI/apple_transcripts/, so reopening a saved conversation restores the
// model's real tool-call/tool-output history instead of Phase 1's lossy text-summary replay.
//
// Transcript itself is Codable at macOS 26.0+ (confirmed by reading the SDK directly), so
// encoding/decoding here needs no availability gate. What DOES need one enforced at the call
// site in AppleFoundationProvider, not here is reading a *live* LanguageModelSession's own
// `.transcript` property, which is macOS 27.0+ only. See the Apple Intelligence tool-calling plan
// (/Users/rune/.claude/plans/apple-intelligence-tool-calling-plan.md), §6, for the full asymmetry:
// reconstructing a session FROM a saved transcript works at 26.0+, capturing one to save does not.
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling Confab or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import Foundation
import FoundationModels
import os
/// One JSON file per conversation, named by UUID directly (no separate DB column needed the
/// filename is fully derived from the conversation's own id, unlike `notes.md`'s human-readable
/// naming, which needs a stored filename since it also embeds the conversation's display name).
/// All operations are best-effort, matching `ConversationNotesService`'s convention a missing or
/// unreadable file just means "no saved session," never a hard error.
nonisolated final class AppleTranscriptService {
static let shared = AppleTranscriptService()
private let baseDirectory: URL = {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first!
return appSupport.appendingPathComponent("oAI/apple_transcripts", isDirectory: true)
}()
private func ensureDirectory() {
try? FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true)
}
nonisolated func fileURL(for conversationId: UUID) -> URL {
baseDirectory.appendingPathComponent("\(conversationId.uuidString).json")
}
/// Persists a session's transcript. Only ever called for a real, saved conversation (never the
/// ephemeral in-memory session key an unsaved chat uses) the caller is responsible for that
/// distinction, since this service only knows about UUIDs, not Confab's conversation model.
func save(_ transcript: Transcript, for conversationId: UUID) {
do {
let data = try JSONEncoder().encode(transcript)
ensureDirectory()
try data.write(to: fileURL(for: conversationId), options: .atomic)
} catch {
Log.api.warning("Failed to save Apple On-Device transcript for \(conversationId.uuidString): \(error.localizedDescription)")
}
}
/// Returns the saved transcript for a conversation, or nil if none exists or it can't be
/// decoded (e.g. hand-edited or corrupted never treated as an error, just "start fresh").
func load(for conversationId: UUID) -> Transcript? {
guard let data = try? Data(contentsOf: fileURL(for: conversationId)) else { return nil }
return try? JSONDecoder().decode(Transcript.self, from: data)
}
/// Removes a conversation's saved transcript call when the conversation itself is deleted, so
/// a stale file doesn't linger for an id that no longer has a conversation behind it.
func delete(for conversationId: UUID) {
try? FileManager.default.removeItem(at: fileURL(for: conversationId))
}
}
+1
View File
@@ -1241,6 +1241,7 @@ final class DatabaseService: Sendable {
if let notesFilename = result.1 {
ConversationNotesService.shared.delete(filename: notesFilename)
}
AppleTranscriptService.shared.delete(for: id)
return result.0
}
+5
View File
@@ -1998,6 +1998,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
streamingTask?.cancel()
let conversationKey = appleSessionKey
// Only a real, already-saved conversation gets its transcript persisted an unsaved chat
// has no durable id to restore from later even if we tried (S-only persistence, no
// autosave). See AppleFoundationProvider.respondWithTools's persistTranscriptId doc comment.
let persistTranscriptId = currentConversationId
let tools = MCPService.shared.getToolSchemas(onlineMode: onlineMode)
let baseInstructions = effectiveSystemPrompt
// Excludes the just-appended current-turn user message only consulted by
@@ -2025,6 +2029,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
do {
let (response, didRebuild) = try await provider.respondWithTools(
conversationId: conversationKey,
persistTranscriptId: persistTranscriptId,
tools: tools,
baseInstructions: baseInstructions,
priorMessagesForRebuildReplay: priorMessages,
@@ -0,0 +1,55 @@
//
// AppleTranscriptServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
import FoundationModels
@testable import Confab
@Suite("AppleTranscriptService")
struct AppleTranscriptServiceTests {
@Test("load() returns nil when no transcript has ever been saved for that conversation")
func loadReturnsNilForMissingFile() {
let neverUsedId = UUID()
#expect(AppleTranscriptService.shared.load(for: neverUsedId) == nil)
}
@Test("save() then load() round-trips a real Transcript value")
func saveThenLoadRoundTrips() {
let testId = UUID()
defer { AppleTranscriptService.shared.delete(for: testId) }
let transcript = Transcript()
AppleTranscriptService.shared.save(transcript, for: testId)
let loaded = AppleTranscriptService.shared.load(for: testId)
#expect(loaded != nil)
}
@Test("delete() removes a saved transcript, and is safe to call when nothing was ever saved")
func deleteRemovesFileAndIsIdempotent() {
let testId = UUID()
AppleTranscriptService.shared.save(Transcript(), for: testId)
#expect(AppleTranscriptService.shared.load(for: testId) != nil)
AppleTranscriptService.shared.delete(for: testId)
#expect(AppleTranscriptService.shared.load(for: testId) == nil)
// Deleting again (nothing left to delete) must not throw or crash.
AppleTranscriptService.shared.delete(for: testId)
}
@Test("fileURL(for:) derives a deterministic, per-conversation path from the UUID alone")
func fileURLIsDeterministicPerConversation() {
let id = UUID()
#expect(AppleTranscriptService.shared.fileURL(for: id) == AppleTranscriptService.shared.fileURL(for: id))
let otherId = UUID()
#expect(AppleTranscriptService.shared.fileURL(for: id) != AppleTranscriptService.shared.fileURL(for: otherId))
}
}