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:
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user