803 lines
35 KiB
Swift
803 lines
35 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AVFoundation
|
|
import AgentKit
|
|
import Foundation
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import TermindPersistence
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
import Testing
|
|
import UIKit
|
|
import UniformTypeIdentifiers
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite @MainActor
|
|
struct MobileAssistantTests {
|
|
@Test func shortUserInputUsesItsNaturalHeightAndLongInputUsesAvailableHeight() throws {
|
|
let availableHeight: CGFloat = 640
|
|
let shortApp = try MobileAppModel(inMemory: true)
|
|
let shortAssistant = shortApp.assistant.makeSceneModel()
|
|
shortAssistant.conversation.present(
|
|
AgentUserInputRequest(questions: [
|
|
AgentUserQuestion(id: "answer", header: "Answer", prompt: "Continue?", kind: .text)
|
|
]))
|
|
let shortHeight = measuredInteractionHeight(
|
|
conversation: shortAssistant.conversation,
|
|
hosts: shortApp.library, speech: shortAssistant.speech,
|
|
availableHeight: availableHeight
|
|
)
|
|
|
|
let longApp = try MobileAppModel(inMemory: true)
|
|
let longAssistant = longApp.assistant.makeSceneModel()
|
|
let options = (0..<16).map { index in
|
|
AgentUserInputOption(
|
|
id: "option-\(index)",
|
|
label: "Option \(index)",
|
|
detail: "A deliberately detailed explanation that makes this option require another line."
|
|
)
|
|
}
|
|
longAssistant.conversation.present(
|
|
AgentUserInputRequest(questions: [
|
|
AgentUserQuestion(
|
|
id: "choice",
|
|
header: "Choose",
|
|
prompt: "Select an option.",
|
|
kind: .choice(AgentUserInputChoice(options: options))
|
|
)
|
|
]))
|
|
let longHeight = measuredInteractionHeight(
|
|
conversation: longAssistant.conversation,
|
|
hosts: longApp.library, speech: longAssistant.speech,
|
|
availableHeight: availableHeight
|
|
)
|
|
|
|
#expect(shortHeight > 0)
|
|
#expect(shortHeight < 300)
|
|
#expect(abs(longHeight - availableHeight) < 0.5)
|
|
#expect(shortHeight < longHeight)
|
|
}
|
|
|
|
@Test func longApprovalUsesAvailableHeightInsteadOfAFixedCap() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
let conversation = assistant.conversation
|
|
let invocation = AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(
|
|
id: "approval",
|
|
name: "write",
|
|
arguments: .object(
|
|
Dictionary(
|
|
uniqueKeysWithValues: (0..<20).map { index in
|
|
("argument-\(index)", AgentJSONValue.string(String(repeating: "value ", count: 12)))
|
|
})
|
|
)
|
|
)
|
|
)
|
|
let request = AgentApprovalRequest(
|
|
invocation: invocation,
|
|
descriptor: AgentToolDescriptor(
|
|
name: "write",
|
|
summary: "Write data",
|
|
inputSchema: .object([:]),
|
|
target: .local,
|
|
approvalPolicy: .ask
|
|
),
|
|
userIntent: "Update the requested data",
|
|
localReasons: (0..<8).map { "Reason \($0) requires confirmation." }
|
|
)
|
|
let approval = Task { await conversation.requestApproval(request) }
|
|
await Task.yield()
|
|
|
|
let availableHeight: CGFloat = 640
|
|
let height = measuredInteractionHeight(
|
|
conversation: conversation,
|
|
hosts: app.library, speech: app.assistant.speech,
|
|
availableHeight: availableHeight
|
|
)
|
|
|
|
#expect(abs(height - availableHeight) < 0.5)
|
|
conversation.respondToApproval(request.id, allow: false)
|
|
if case .deny = await approval.value {
|
|
} else {
|
|
Issue.record("Expected the approval request to be denied during cleanup")
|
|
}
|
|
}
|
|
|
|
@Test func speechTapHandlerRunsOnTheRealtimeQueueWithoutActorIsolation() async throws {
|
|
let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1))
|
|
let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 1))
|
|
buffer.frameLength = 1
|
|
buffer.floatChannelData?[0][0] = 0.25
|
|
let probe = SpeechTapProbe()
|
|
let invocation = SpeechTapInvocation(
|
|
buffer: buffer,
|
|
handler: IOSSpeechAudioInput.makeTapHandler(
|
|
bufferHandler: { _ in probe.recordBuffer() },
|
|
levelHandler: { probe.recordLevel($0) }
|
|
)
|
|
)
|
|
|
|
await withCheckedContinuation { continuation in
|
|
DispatchQueue.global().async {
|
|
invocation.handler(invocation.buffer, AVAudioTime(hostTime: 0))
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
#expect(probe.snapshot().buffers == 1)
|
|
#expect(probe.snapshot().level > 0)
|
|
}
|
|
|
|
@Test func sharedTranscriptProjectionKeepsStableKindsAndEditCapabilities() {
|
|
let user = ChatMessage(role: .user, text: "Inspect")
|
|
let assistant = ChatMessage(role: .assistant, text: "Done")
|
|
let entries = AssistantTranscriptEntry.items(
|
|
timeline: [.message(user), .message(assistant)],
|
|
isInterrupted: true,
|
|
canEdit: { $0 == user.id },
|
|
canFork: { $0 == assistant.id }
|
|
)
|
|
|
|
#expect(entries.map(\.kind) == [.user, .assistant, .interrupted])
|
|
if case .user(_, let canEdit) = entries[0].content { #expect(canEdit) } else { Issue.record("Missing user") }
|
|
if case .assistant(_, let canFork) = entries[1].content {
|
|
#expect(canFork)
|
|
} else {
|
|
Issue.record("Missing assistant")
|
|
}
|
|
}
|
|
|
|
@Test func swiftDataRestoreAndEditPreserveASameRunPrefixOnMobile() async throws {
|
|
let container = try TermindPersistence.makeContainer(inMemory: true)
|
|
let repository = SwiftDataAgentRunRepository(context: container.mainContext)
|
|
let instant = Date(timeIntervalSinceReferenceDate: 100)
|
|
let runID = try #require(UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"))
|
|
let replacementID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001"))
|
|
let opening = AgentTranscriptMessage(
|
|
role: .user, text: "Opening", authoredText: "Opening", createdAt: instant)
|
|
let prefix = AgentTranscriptMessage(
|
|
role: .assistant, text: "Prefix", createdAt: instant)
|
|
let steering = AgentTranscriptMessage(
|
|
role: .user, text: "Steer", authoredText: "Steer", createdAt: instant)
|
|
let suffix = AgentTranscriptMessage(
|
|
role: .assistant, text: "Suffix", createdAt: instant)
|
|
try await repository.save(
|
|
AgentRunSnapshot(
|
|
id: runID, conversationID: UUID(), state: .failed,
|
|
permissionMode: .askForApproval,
|
|
messages: [opening, prefix, steering, suffix],
|
|
startedAt: instant, finishedAt: instant, failure: "old failure"))
|
|
let store = AssistantConversationStore()
|
|
store.restore(try await repository.loadConversations())
|
|
let conversation = try #require(store.conversations.first)
|
|
let replacement = AgentTranscriptMessage(
|
|
id: replacementID, role: .user, text: "Replacement", authoredText: "Replacement",
|
|
createdAt: instant)
|
|
|
|
let operations = AssistantConversationOperations(repository: repository)
|
|
#expect(
|
|
try await operations.edit(
|
|
conversation, messageID: steering.id, replacement: replacement) != nil)
|
|
|
|
#expect(conversation.messages.map(\.text) == ["Opening", "Prefix", "Replacement"])
|
|
#expect(conversation.messageRunIDs[opening.id] == runID)
|
|
#expect(conversation.messageRunIDs[prefix.id] == runID)
|
|
#expect(conversation.messageRunIDs[replacement.id] == replacement.id)
|
|
#expect(try #require(conversation.runSummaries[replacement.id]).startedAt > instant)
|
|
let reopened = try #require(try await repository.loadConversations().first)
|
|
#expect(reopened.messages.map(\.text) == ["Opening", "Prefix", "Replacement"])
|
|
#expect(reopened.messageRunIDs[opening.id] == runID)
|
|
#expect(reopened.messageRunIDs[prefix.id] == runID)
|
|
#expect(reopened.messageRunIDs[replacement.id] == replacement.id)
|
|
#expect(reopened.runs.map(\.id) == [runID, replacement.id])
|
|
#expect(reopened.runs.first { $0.id == runID }?.state == .completed)
|
|
#expect(reopened.runs.first { $0.id == runID }?.failure == nil)
|
|
}
|
|
|
|
@Test func changingConversationsPreservesTheirDraftsAndArchiveNavigation() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let model = app.assistant.makeSceneModel()
|
|
let first = ConversationModel(messages: [ChatMessage(role: .user, text: "First")])
|
|
let second = ConversationModel(messages: [ChatMessage(role: .user, text: "Second")])
|
|
model.library.insertAndSelect(first)
|
|
first.composer.draft = "first draft"
|
|
model.library.insertAndSelect(second)
|
|
second.composer.draft = "second draft"
|
|
model.select(first)
|
|
#expect(model.conversation.composer.draft == "first draft")
|
|
model.select(second)
|
|
#expect(model.conversation.composer.draft == "second draft")
|
|
#expect(first.composer.draft == "first draft")
|
|
}
|
|
|
|
@Test func explicitNewConversationStartsWithAFreshDraft() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
assistant.conversation.composer.draft = "discard this explicit new-chat draft"
|
|
|
|
assistant.newConversation()
|
|
|
|
#expect(assistant.conversation.isEmpty)
|
|
#expect(assistant.conversation.composer.draft.isEmpty)
|
|
}
|
|
|
|
@Test func firstNewConversationSendPromotesDraftAndStartsRun() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
let draft = assistant.conversation
|
|
draft.composer.draft = "Why is the service unavailable?"
|
|
|
|
assistant.send()
|
|
|
|
#expect(assistant.library.selectedConversation === draft)
|
|
#expect(assistant.library.conversations.map(\.id) == [draft.id])
|
|
#expect(assistant.library.draftConversation !== draft)
|
|
#expect(draft.composer.draft.isEmpty)
|
|
#expect(draft.messages.map(\.role) == [.user])
|
|
#expect(draft.messages.first?.text == "Why is the service unavailable?")
|
|
#expect(draft.transcript.map(\.role) == [.user])
|
|
#expect(await eventually { draft.runState == .failed })
|
|
try expectMissingModelFailure(in: draft)
|
|
}
|
|
|
|
@Test func attachmentOnlyFirstSendPromotesTheConversation() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
let draft = assistant.conversation
|
|
draft.attach(ContextChip(kind: .file, label: "service.log", content: "connection refused"))
|
|
|
|
assistant.send()
|
|
|
|
#expect(assistant.library.selectedConversation === draft)
|
|
#expect(assistant.library.conversations.map(\.id) == [draft.id])
|
|
#expect(draft.messages.map(\.role) == [.user])
|
|
#expect(draft.messages.first?.text.isEmpty == true)
|
|
#expect(draft.messages.first?.contextChips.map(\.label) == ["service.log"])
|
|
#expect(draft.transcript.first?.text.contains("connection refused") == true)
|
|
#expect(await eventually { draft.runState == .failed })
|
|
try expectMissingModelFailure(in: draft)
|
|
}
|
|
|
|
@Test func planImplementationStartsAnActingTurnWithPendingComposerInputs() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
let conversation = ConversationModel()
|
|
let plan = AgentPlan(
|
|
title: "Implementation plan", body: "Apply the requested change.",
|
|
scratchPath: "/tmp/plan.md")
|
|
conversation.isPlanning = true
|
|
conversation.handle(.planPresented(plan))
|
|
conversation.composer.armedSkillName = "disk-triage"
|
|
conversation.attach(ContextChip(kind: .file, label: "service.log", content: "connection refused"))
|
|
let card = try #require(conversation.plans.first)
|
|
|
|
assistant.implement(card, in: conversation)
|
|
|
|
#expect(conversation.plans.first?.outcome == .implemented)
|
|
#expect(!conversation.isPlanning)
|
|
let turn = try #require(conversation.transcript.last)
|
|
#expect(turn.contextSnapshot?.planContract == nil)
|
|
#expect(turn.text.contains("connection refused"))
|
|
#expect(turn.text.contains("<use_skill name=\"disk-triage\">"))
|
|
#expect(conversation.composer.attachments.isEmpty)
|
|
#expect(conversation.composer.armedSkillName == nil)
|
|
#expect(await eventually { conversation.runState == .failed })
|
|
try expectMissingModelFailure(in: conversation)
|
|
}
|
|
|
|
@Test func existingConversationSendStillUsesTheDraftExecutionPath() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
let conversation = ConversationModel()
|
|
conversation.composer.draft = "Opening question"
|
|
conversation.sendDraft()
|
|
assistant.library.insertAndSelect(conversation)
|
|
conversation.composer.draft = "Follow-up question"
|
|
|
|
assistant.send()
|
|
|
|
#expect(conversation.composer.draft.isEmpty)
|
|
#expect(conversation.messages.map(\.text).contains("Follow-up question"))
|
|
#expect(conversation.transcript.last?.contextSnapshot?.toolAvailability?["terminal_suggest"] == false)
|
|
#expect(await eventually { conversation.runState == .failed })
|
|
#expect(conversation.messages.map(\.role) == [.user, .user])
|
|
#expect(conversation.transcript.map(\.role) == [.user, .user])
|
|
try expectMissingModelFailure(in: conversation)
|
|
}
|
|
|
|
@Test func steeringFromMobileSceneDisablesTerminalSuggestionsInTheQueuedSnapshot() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
assistant.library.insertAndSelect(conversation)
|
|
conversation.start(
|
|
runtime: AgentRuntime(
|
|
model: MobileDelayedAgentModel(), tools: [],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: repository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "start",
|
|
permissionMode: .askForApproval,
|
|
contextSnapshot: .init(
|
|
toolAvailability: ["terminal_suggest": true, "future_surface_tool": false])),
|
|
repository: repository
|
|
)
|
|
conversation.composer.draft = "Inspect again"
|
|
|
|
assistant.send()
|
|
|
|
let snapshot = try #require(conversation.pendingSteering.first?.message.contextSnapshot)
|
|
#expect(snapshot.toolAvailability?["terminal_suggest"] == false)
|
|
#expect(snapshot.toolAvailability?["future_surface_tool"] == false)
|
|
#expect(await eventually { conversation.runState.isTerminal })
|
|
}
|
|
|
|
@Test func slashCatalogShowsAlphabetizedCompactPlanAndSkillCommandsOnMobile() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
let conversation = ConversationModel(messages: [ChatMessage(role: .user, text: "Inspect")])
|
|
assistant.library.insertAndSelect(conversation)
|
|
let composer = MobileAssistantComposer(model: assistant, conversation: conversation)
|
|
let commands = composer.availableCommands
|
|
let first = try #require(commands.first)
|
|
#expect(commands.contains { $0.id == "plan" })
|
|
#expect(commands.contains { $0.id == "compact" })
|
|
#expect(Set(commands.map(\.id)).isSubset(of: Set(["compact", "plan", "skill"])))
|
|
#expect(
|
|
commands.map(\.displayName)
|
|
== commands.map(\.displayName).sorted {
|
|
$0.localizedCaseInsensitiveCompare($1) == .orderedAscending
|
|
}
|
|
)
|
|
#expect(commands.allSatisfy { $0.platforms.contains(.mobile) })
|
|
#expect(!first.summary.isEmpty)
|
|
}
|
|
|
|
@Test func selectingAnExplicitModelRemembersItAndClampsEffort() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
var reasoning = AIModel(id: "reasoning-model")
|
|
reasoning.abilities.insert(.reasoning)
|
|
reasoning.reasoningProfile = ReasoningProfile(levels: [
|
|
.off: ReasoningLevelMapping(isSupported: true),
|
|
.high: ReasoningLevelMapping(isSupported: true),
|
|
])
|
|
var plain = AIModel(id: "plain-model")
|
|
plain.reasoningProfile = ReasoningProfile(levels: [
|
|
.off: ReasoningLevelMapping(isSupported: true)
|
|
])
|
|
let provider = ModelProvider(name: "Test", models: [reasoning, plain])
|
|
app.ai.upsert(provider)
|
|
|
|
assistant.conversation.reasoningEffort = .low
|
|
let reasoningRef = AIModelRef(modelProviderID: provider.id, modelID: reasoning.id)
|
|
assistant.selectModel(reasoningRef)
|
|
#expect(assistant.conversation.model == reasoningRef)
|
|
#expect(app.ai.lastSelectedModel == reasoningRef)
|
|
#expect(assistant.conversation.reasoningEffort == .high)
|
|
|
|
let plainRef = AIModelRef(modelProviderID: provider.id, modelID: plain.id)
|
|
assistant.selectModel(plainRef)
|
|
#expect(assistant.conversation.reasoningEffort == .off)
|
|
}
|
|
|
|
@Test func assistantDropImporterAcceptsFileURLsAndImageData() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let conversation = app.assistant.makeSceneModel().conversation
|
|
let directory = FileManager.default.temporaryDirectory.appending(
|
|
path: UUID().uuidString, directoryHint: .isDirectory)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let file = directory.appending(path: "service.log")
|
|
try Data("connection refused".utf8).write(to: file)
|
|
let image = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8)).image { context in
|
|
UIColor.systemBlue.setFill()
|
|
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
|
|
}
|
|
let imageData = try #require(image.pngData())
|
|
|
|
let fileProvider = try #require(NSItemProvider(contentsOf: file))
|
|
let imageProvider = NSItemProvider(
|
|
item: imageData as NSData,
|
|
typeIdentifier: UTType.png.identifier
|
|
)
|
|
let payload = await MobileDropPayloadLoader.load(
|
|
[fileProvider, imageProvider],
|
|
kinds: [.files, .images],
|
|
fileMode: .staged
|
|
)
|
|
|
|
let stagedFile = try #require(payload.fileURLs.first)
|
|
defer {
|
|
if let stagedRoot = payload.stagedRoot {
|
|
try? FileManager.default.removeItem(at: stagedRoot)
|
|
}
|
|
}
|
|
#expect(stagedFile != file)
|
|
#expect(stagedFile.lastPathComponent == file.lastPathComponent)
|
|
#expect(try Data(contentsOf: stagedFile) == Data("connection refused".utf8))
|
|
#expect(payload.images == [imageData])
|
|
#expect(payload.failures.isEmpty)
|
|
conversation.importAttachmentFiles(payload.fileURLs)
|
|
conversation.importAttachmentImages(payload.images, label: String(localized: "Dropped image"))
|
|
#expect(await eventually { conversation.composer.pendingAttachments.isEmpty })
|
|
#expect(Set(conversation.composer.attachments.map(\.label)) == ["service.log", "Dropped image"])
|
|
#expect(
|
|
conversation.composer.attachments.contains {
|
|
$0.label == "service.log" && $0.content == "connection refused"
|
|
}
|
|
)
|
|
#expect(conversation.composer.attachments.contains { $0.image != nil })
|
|
}
|
|
|
|
@Test func completeRegistryIncludesRemoteAndBuiltInToolsOnlyForCapableModels() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let hosts = AgentHostResolver(store: app.library)
|
|
let connections = AgentConnectionPool(
|
|
configuration: { _ in throw CancellationError() },
|
|
routeFingerprint: { _ in "" }, openConnection: { _ in throw CancellationError() },
|
|
openSFTP: { _ in throw CancellationError() }, sweepInterval: nil)
|
|
let authentication = AgentSSHAuthenticationRegistry(resolver: hosts)
|
|
let background = AgentBackgroundCommands(sweepInterval: nil)
|
|
let repository = InMemoryAgentRunRepository()
|
|
let channel = AgentEventChannel(repository: repository)
|
|
var model = AIModel(id: "test")
|
|
model.abilities = [.toolCall]
|
|
let provider = ModelProvider(name: "Test")
|
|
let conversation = ConversationModel()
|
|
func registry(_ model: AIModel) -> AgentToolRegistry {
|
|
AssistantToolRegistry.make(
|
|
conversation: conversation, model: model, provider: provider,
|
|
skills: app.skills.catalog, channel: channel, ai: app.ai, hosts: hosts, metrics: app.metrics.core,
|
|
connections: connections, authentication: authentication, background: background)
|
|
}
|
|
let mobileDescriptors = registry(model).descriptors
|
|
let names = Set(mobileDescriptors.map(\.name))
|
|
for name in [
|
|
"ssh_exec_run", "ssh_exec_output", "ssh_exec_stdin", "ssh_exec_cancel", "ssh_exec_list", "sftp_read",
|
|
"sftp_write", "list_hosts", "get_metrics",
|
|
"scratch_pull", "scratch_push", "present_plan", "request_user_input", "request_user_secret",
|
|
"terminal_suggest",
|
|
] {
|
|
#expect(names.contains(name), "Missing tool: \(name)")
|
|
}
|
|
let exec = try #require(mobileDescriptors.first { $0.name == "ssh_exec_run" })
|
|
#expect(exec.summary.contains("Discover host_id with list_hosts"))
|
|
#expect(exec.summary.contains("Termind handles login"))
|
|
try AgentJSONSchemaValidator.validate(
|
|
.object(["host_id": .string(UUID().uuidString), "command": .string("uname -a")]),
|
|
against: exec.inputSchema
|
|
)
|
|
model.abilities = []
|
|
#expect(registry(model).descriptors.isEmpty)
|
|
}
|
|
|
|
@Test func backgroundExpirationBlocksNewRunsUntilCleanupCompletes() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
assistant.conversation.composer.draft = "preserve"
|
|
app.assistant.expireBackground()
|
|
#expect(app.assistant.isSuspending)
|
|
assistant.send()
|
|
#expect(assistant.conversation.composer.draft == "preserve")
|
|
await assistant.compact(assistant.conversation)
|
|
#expect(assistant.conversation.compactionActivity == nil)
|
|
await app.assistant.waitForSuspension()
|
|
#expect(!app.assistant.isSuspending)
|
|
#expect(!app.assistant.isRunning)
|
|
}
|
|
|
|
@Test func backgroundExpirationBlocksEditsUntilCleanupCompletesThenAllowsRecovery() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
let conversation = assistant.conversation
|
|
conversation.composer.draft = "Original question"
|
|
assistant.send()
|
|
#expect(await eventually { conversation.runState == .failed })
|
|
let original = try #require(conversation.messages.first { $0.role == .user })
|
|
let blocker = ConversationModel()
|
|
assistant.library.insertAndSelect(blocker)
|
|
let blockerRepository = MobileTerminalSaveGateRepository()
|
|
blocker.start(
|
|
runtime: AgentRuntime(
|
|
model: MobileImmediateAssistantModel(calls: MobileAssistantModelCalls()), tools: [],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: blockerRepository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: blocker.id, prompt: "block cleanup", permissionMode: .askForApproval),
|
|
repository: blockerRepository
|
|
)
|
|
await blockerRepository.waitForTerminalSave()
|
|
|
|
app.assistant.expireBackground()
|
|
#expect(app.assistant.isSuspending)
|
|
assistant.edit(conversation, messageID: original.id, text: "Replacement question")
|
|
await Task.yield()
|
|
#expect(conversation.messages.first(where: { $0.role == .user })?.text == "Original question")
|
|
|
|
await blockerRepository.releaseTerminalSaves()
|
|
await app.assistant.waitForSuspension()
|
|
#expect(!app.assistant.isSuspending)
|
|
assistant.edit(conversation, messageID: original.id, text: "Replacement question")
|
|
#expect(
|
|
await eventually {
|
|
conversation.messages.first(where: { $0.role == .user })?.text == "Replacement question"
|
|
}
|
|
)
|
|
#expect(await eventually { conversation.runState == .failed })
|
|
}
|
|
|
|
@Test func backgroundExpirationBlocksPlanImplementationUntilCleanupCompletesThenAllowsRecovery() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
let conversation = ConversationModel()
|
|
let plan = AgentPlan(
|
|
title: "Implementation plan", body: "Apply the requested change.",
|
|
scratchPath: "/tmp/plan.md")
|
|
conversation.isPlanning = true
|
|
conversation.handle(.planPresented(plan))
|
|
let card = try #require(conversation.plans.first)
|
|
let blocker = ConversationModel()
|
|
assistant.library.insertAndSelect(blocker)
|
|
let blockerRepository = MobileTerminalSaveGateRepository()
|
|
blocker.start(
|
|
runtime: AgentRuntime(
|
|
model: MobileImmediateAssistantModel(calls: MobileAssistantModelCalls()), tools: [],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: blockerRepository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: blocker.id, prompt: "block cleanup", permissionMode: .askForApproval),
|
|
repository: blockerRepository
|
|
)
|
|
await blockerRepository.waitForTerminalSave()
|
|
|
|
app.assistant.expireBackground()
|
|
#expect(app.assistant.isSuspending)
|
|
assistant.implement(card, in: conversation)
|
|
#expect(conversation.plans.first?.outcome == .pending)
|
|
#expect(conversation.transcript.isEmpty)
|
|
|
|
await blockerRepository.releaseTerminalSaves()
|
|
await app.assistant.waitForSuspension()
|
|
#expect(!app.assistant.isSuspending)
|
|
assistant.implement(card, in: conversation)
|
|
#expect(conversation.plans.first?.outcome == .implemented)
|
|
let implementation = try #require(conversation.transcript.last)
|
|
#expect(implementation.role == .user)
|
|
#expect(implementation.contextSnapshot?.planContract == nil)
|
|
#expect(await eventually { conversation.runState == .failed })
|
|
}
|
|
|
|
@Test func backgroundExpirationStopsEveryConversationBeforeJoiningTheFirstDrain() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let assistant = app.assistant.makeSceneModel()
|
|
let first = ConversationModel()
|
|
let second = ConversationModel()
|
|
assistant.library.insertAndSelect(second)
|
|
assistant.library.insertAndSelect(first)
|
|
assistant.select(second)
|
|
#expect(app.assistant.store.conversations.map(\.id) == [first.id, second.id])
|
|
let secondCalls = MobileAssistantModelCalls()
|
|
let firstRepository = MobileTerminalSaveGateRepository()
|
|
let secondRepository = InMemoryAgentRunRepository()
|
|
first.start(
|
|
runtime: AgentRuntime(
|
|
model: MobileImmediateAssistantModel(calls: MobileAssistantModelCalls()), tools: [],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: firstRepository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: first.id, prompt: "first", permissionMode: .askForApproval),
|
|
repository: firstRepository
|
|
)
|
|
await firstRepository.waitForTerminalSave()
|
|
second.start(
|
|
runtime: AgentRuntime(
|
|
model: MobileImmediateAssistantModel(calls: secondCalls), tools: [],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: secondRepository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: second.id, prompt: "second", permissionMode: .askForApproval),
|
|
repository: secondRepository
|
|
)
|
|
|
|
app.assistant.expireBackground()
|
|
app.assistant.expireBackground()
|
|
|
|
#expect(app.assistant.isSuspending)
|
|
#expect(!first.isStreaming)
|
|
#expect(first.runState.isTerminal)
|
|
#expect(second.runState == .cancelled)
|
|
second.composer.draft = "preserve"
|
|
assistant.send()
|
|
#expect(second.composer.draft == "preserve")
|
|
#expect(secondCalls.count == 0)
|
|
let join = MobileAssistantJoinProbe()
|
|
let waiter = Task { @MainActor in
|
|
join.markStarted()
|
|
await app.assistant.waitForSuspension()
|
|
join.markFinished()
|
|
}
|
|
await join.waitUntilStarted()
|
|
#expect(!join.finished)
|
|
#expect(app.assistant.isSuspending)
|
|
|
|
await firstRepository.releaseTerminalSaves()
|
|
await waiter.value
|
|
#expect(!app.assistant.isSuspending)
|
|
#expect(secondCalls.count == 0)
|
|
}
|
|
|
|
private func measuredInteractionHeight(
|
|
conversation: ConversationModel,
|
|
hosts: HostLibraryModel,
|
|
speech: SpeechTranscriptionCoordinator,
|
|
width: CGFloat = 390,
|
|
availableHeight: CGFloat = 1_000
|
|
) -> CGFloat {
|
|
let controller = UIHostingController(
|
|
rootView: MobileAssistantInteraction(conversation: conversation, hosts: hosts, speech: speech)
|
|
.environment(\.dynamicTypeSize, .large)
|
|
)
|
|
return controller.sizeThatFits(in: CGSize(width: width, height: availableHeight)).height
|
|
}
|
|
|
|
}
|
|
|
|
private struct MobileDelayedAgentModel: AgentModelStreaming {
|
|
func stream(_ request: AgentModelRequest) -> AsyncThrowingStream<AgentModelStreamEvent, any Error> {
|
|
AsyncThrowingStream { continuation in
|
|
Task {
|
|
try? await Task.sleep(for: .milliseconds(200))
|
|
continuation.yield(.finished(.completed))
|
|
continuation.finish()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated private final class MobileAssistantModelCalls: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var callCount = 0
|
|
|
|
func called() {
|
|
lock.withLock { callCount += 1 }
|
|
}
|
|
|
|
var count: Int { lock.withLock { callCount } }
|
|
}
|
|
|
|
@MainActor
|
|
private final class MobileAssistantJoinProbe {
|
|
private(set) var finished = false
|
|
private var started = false
|
|
private var startWaiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
func markStarted() {
|
|
started = true
|
|
let waiters = startWaiters
|
|
startWaiters.removeAll()
|
|
for waiter in waiters { waiter.resume() }
|
|
}
|
|
|
|
func waitUntilStarted() async {
|
|
guard !started else { return }
|
|
await withCheckedContinuation { startWaiters.append($0) }
|
|
}
|
|
|
|
func markFinished() { finished = true }
|
|
}
|
|
|
|
private struct MobileImmediateAssistantModel: AgentModelStreaming {
|
|
let calls: MobileAssistantModelCalls
|
|
|
|
func stream(_ request: AgentModelRequest) -> AsyncThrowingStream<AgentModelStreamEvent, any Error> {
|
|
AsyncThrowingStream { continuation in
|
|
calls.called()
|
|
continuation.yield(.finished(.completed))
|
|
continuation.finish()
|
|
}
|
|
}
|
|
}
|
|
|
|
private actor MobileTerminalSaveGateRepository: AgentRunPersisting {
|
|
private var terminalSaveEntered = false
|
|
private var terminalSaveWaiters: [CheckedContinuation<Void, Never>] = []
|
|
private var terminalReleaseWaiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
func save(_ snapshot: AgentRunSnapshot) async {
|
|
guard snapshot.state.isTerminal else { return }
|
|
terminalSaveEntered = true
|
|
let waiters = terminalSaveWaiters
|
|
terminalSaveWaiters.removeAll()
|
|
for waiter in waiters { waiter.resume() }
|
|
await withCheckedContinuation { terminalReleaseWaiters.append($0) }
|
|
}
|
|
|
|
func waitForTerminalSave() async {
|
|
guard !terminalSaveEntered else { return }
|
|
await withCheckedContinuation { terminalSaveWaiters.append($0) }
|
|
}
|
|
|
|
func releaseTerminalSaves() {
|
|
let waiters = terminalReleaseWaiters
|
|
terminalReleaseWaiters.removeAll()
|
|
for waiter in waiters { waiter.resume() }
|
|
}
|
|
|
|
func record(_ event: AgentEvent, runID: UUID) {}
|
|
func persistCompaction(_ record: AgentCompactionRecord, conversationID: UUID) {}
|
|
func loadConversations() -> [StoredAgentConversation] { [] }
|
|
func deleteConversation(_ id: UUID) {}
|
|
func setTitle(_ title: String, conversationID: UUID) {}
|
|
func setConversationMetadata(_ metadata: StoredAgentConversationMetadata, conversationID: UUID) {}
|
|
func reorderConversations(_ updates: [StoredAgentConversationOrder]) {}
|
|
func truncateConversation(
|
|
_ id: UUID, fromUserMessageID messageID: UUID,
|
|
replacingWith message: AgentTranscriptMessage
|
|
) {}
|
|
func createConversation(_ conversation: StoredAgentConversation) {}
|
|
}
|
|
|
|
@MainActor
|
|
private func expectMissingModelFailure(in conversation: ConversationModel) throws {
|
|
// In-memory apps have no Chat model. That must produce a failure row, not an authored answer.
|
|
#expect(conversation.runState == .failed)
|
|
#expect(conversation.runSummaries.count == 1)
|
|
let run = try #require(conversation.runSummaries.values.first)
|
|
#expect(run.state == .failed)
|
|
#expect(run.finishedAt != nil)
|
|
let reason = try #require(run.failure)
|
|
#expect(!reason.isEmpty)
|
|
let prompt = try #require(conversation.transcript.last)
|
|
#expect(conversation.messageRunIDs[prompt.id] == run.id)
|
|
let failures = conversation.timeline.compactMap { item -> AgentRunFailureNotice? in
|
|
guard case .failure(let failure) = item else { return nil }
|
|
return failure
|
|
}
|
|
#expect(failures.count == 1)
|
|
#expect(failures.first?.runID == run.id)
|
|
#expect(failures.first?.reason == reason)
|
|
#expect(AssistantExecutionController.titleContext(for: conversation) == nil)
|
|
}
|
|
|
|
nonisolated private final class SpeechTapInvocation: @unchecked Sendable {
|
|
let buffer: AVAudioPCMBuffer
|
|
let handler: AVAudioNodeTapBlock
|
|
|
|
init(buffer: AVAudioPCMBuffer, handler: @escaping AVAudioNodeTapBlock) {
|
|
self.buffer = buffer
|
|
self.handler = handler
|
|
}
|
|
}
|
|
|
|
nonisolated private final class SpeechTapProbe: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var buffers = 0
|
|
private var level: Float = 0
|
|
|
|
func recordBuffer() {
|
|
lock.withLock { buffers += 1 }
|
|
}
|
|
|
|
func recordLevel(_ value: Float) {
|
|
lock.withLock { level = value }
|
|
}
|
|
|
|
func snapshot() -> (buffers: Int, level: Float) {
|
|
lock.withLock { (buffers, level) }
|
|
}
|
|
}
|