diff --git a/Termind/Application/Agent/AgentRuntime.swift b/Termind/Application/Agent/AgentRuntime.swift index ad7de27f..f855bb65 100644 --- a/Termind/Application/Agent/AgentRuntime.swift +++ b/Termind/Application/Agent/AgentRuntime.swift @@ -44,6 +44,14 @@ actor AgentRuntime { private let channel: AgentEventChannel private var runTask: Task? private var steering: [AgentTranscriptMessage] = [] + /// Whether a message queued *now* would still be delivered. + /// + /// Not the same question as "is there a run task". `complete` awaits its + /// final persist before it ends the run, and an actor is reentrant at every + /// await — so a message accepted during that window would land in a queue + /// nobody drains again. Cleared as the *first* statement of every path that + /// ends a run, which is what makes the window empty rather than narrow. + private var acceptsSteering = false private var activeRunID: UUID? init( @@ -96,19 +104,34 @@ actor AgentRuntime { /// an `AsyncStream` has a single consumer. func start(_ request: AgentRunRequest) -> AsyncStream { cancel() + // Before the task exists, not inside it. A message queued in the gap + // would otherwise be refused for want of a run that is already on its + // way, and the first drain happens ahead of the first provider request + // — so even something typed this early is delivered rather than lost. + acceptsSteering = true runTask = Task { [weak self] in await self?.run(request) } return channel.events } - func steer(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - steering.append(AgentTranscriptMessage(role: .user, text: trimmed)) + /// Queues a message for the next model boundary. + /// + /// `false` means there is no run left to steer and the caller still owns + /// the text — it must put it somewhere the user can see, because nothing + /// here will ever send it. + @discardableResult + func steer(_ message: AgentTranscriptMessage) -> Bool { + guard acceptsSteering else { return false } + guard !message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + steering.append(message) + return true } func cancel() { + acceptsSteering = false runTask?.cancel() runTask = nil if let runID = activeRunID { Task { await secretBroker.discardSecrets(for: runID) } } @@ -159,11 +182,11 @@ actor AgentRuntime { do { for index in 0.. [AgentTranscriptMessage] { + let delivered = steering.map { queued in + var message = queued + message.createdAt = .now + return message + } + steering.removeAll() + for message in delivered { channel.emit(.steeringDelivered(message)) } + return delivered + } + private func complete(_ snapshot: AgentRunSnapshot, runID: UUID) async { + acceptsSteering = false var finished = snapshot finished.state = .completed finished.finishedAt = .now diff --git a/Termind/Design/DesignTokens.swift b/Termind/Design/DesignTokens.swift index 8dab12da..711e9fad 100644 --- a/Termind/Design/DesignTokens.swift +++ b/Termind/Design/DesignTokens.swift @@ -183,6 +183,14 @@ nonisolated enum Metrics { /// and this card sits between the transcript and the composer — past /// this it would push the conversation it is about off the screen. static let userInputMaxHeight: CGFloat = 320 + + /// How much of the steering queue is shown before it scrolls. + /// + /// A ceiling, not a height — the strip is measured from its rows and + /// only reaches this with four or five of them waiting. Queuing that + /// much at a working agent is possible but rare, and this is a reminder + /// of what is waiting, not a second transcript to read. + static let steeringQueueMaxHeight: CGFloat = 88 } enum TransferCenter { diff --git a/Termind/Domain/Agent/AgentTypes.swift b/Termind/Domain/Agent/AgentTypes.swift index 68cc1f82..771b9932 100644 --- a/Termind/Domain/Agent/AgentTypes.swift +++ b/Termind/Domain/Agent/AgentTypes.swift @@ -268,6 +268,14 @@ nonisolated enum AgentEvent: Hashable, Sendable { case messageStarted(AgentTranscriptMessage) case messageDelta(messageID: AgentTranscriptMessage.ID, text: String) case messageFinished(AgentTranscriptMessage) + /// A message the user queued mid-run has entered the model's context. + /// + /// The one moment at which a steered turn becomes part of the conversation. + /// A reader that posts the bubble when the user pressed send instead + /// records it in a place the model never saw it — before the assistant text + /// that actually preceded it — and replays that order as history on the + /// next run. `createdAt` is restamped at delivery for the same reason. + case steeringDelivered(AgentTranscriptMessage) case toolProposed(AgentToolInvocation, AgentToolDescriptor) case toolStarted(AgentToolInvocation) case toolFinished(AgentToolInvocation, AgentToolResult) diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index be844d43..49134367 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -120,6 +120,22 @@ nonisolated struct AgentToolCard: Identifiable, Hashable, Sendable { var review: SecurityReviewDecision? } +/// One message the user sent while a run was in flight, waiting on the next +/// model boundary. +/// +/// `draft` and `attachments` are kept beside the composed message because the +/// queue is a promise that can be broken — a stopped run hands these back to the +/// composer, and handing back `message.text` would return the user their own +/// attachment blocks to edit. +nonisolated struct PendingSteeringMessage: Identifiable, Hashable, Sendable { + /// The message's own id, so delivery is matched on identity rather than on + /// comparing text with text. + var id: AgentTranscriptMessage.ID { message.id } + var draft: String + var attachments: [ContextChip] = [] + var message: AgentTranscriptMessage +} + /// One row of a conversation, whichever of the two lists it came from. nonisolated enum TimelineItem: Identifiable, Hashable, Sendable { case message(ChatMessage) @@ -212,6 +228,9 @@ final class ConversationModel: Identifiable { /// someone to authorize three things while reading one. private(set) var pendingApprovals: [AgentApprovalRequest] = [] private(set) var pendingUserInputs: [AgentUserInputRequest] = [] + /// What the user typed at a busy agent, oldest first, none of it delivered + /// yet. See `submitSteeringDraft`. + private(set) var pendingSteering: [PendingSteeringMessage] = [] /// Reviews in flight. Counted rather than flagged: they run concurrently, /// so the last one to finish must not be able to announce that none are. private var reviewing: Set = [] @@ -422,22 +441,55 @@ final class ConversationModel: Identifiable { if let runtime { Task { await runtime.cancel() } } runtime = nil if !runtimeState.isTerminal { runtimeState = .cancelled } + returnAllSteeringToDraft() refreshRunState() } - func steer(_ text: String) { - guard let runtime else { return } - Task { await runtime.steer(text) } - } - - /// Queues a visible user turn for the next model boundary without replacing - /// the active runtime or interrupting an in-flight tool. + /// Queues the draft for the running agent instead of starting a second run. + /// + /// Nothing reaches `messages` or `transcript` here. A bubble posted at the + /// keystroke sits above the assistant text that actually preceded it, and + /// `transcript` is replayed as the next run's history — so the model would + /// later be shown a conversation that never happened. The bubble appears + /// when the runtime reports the message delivered, which is where the model + /// really saw it. func submitSteeringDraft() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, runtime != nil else { return } - appendUserTurn(text) + guard !text.isEmpty, let runtime else { return } + let pending = PendingSteeringMessage( + draft: text, attachments: attachments, + message: AgentTranscriptMessage(role: .user, text: composed(text)) + ) + pendingSteering.append(pending) + attachments.removeAll() draft = "" - steer(text) + Task { [weak self] in + guard await runtime.steer(pending.message) == false else { return } + // The run ended between the keystroke and this hop. Everything + // still queued is undeliverable, not just this one — and returning + // the whole queue at once is also what keeps the words in the order + // they were typed, which racing hops resolving one at a time would + // not. + self?.returnAllSteeringToDraft() + } + } + + /// Hands one queued message back to the composer. + /// + /// Called by the ✕ on a queued row, and by every path that ends a run with + /// something still undelivered. Dropping the text instead would be the + /// second time the same words disappeared without being sent. + func returnSteeringToDraft(_ id: AgentTranscriptMessage.ID) { + guard let index = pendingSteering.firstIndex(where: { $0.id == id }) else { return } + let pending = pendingSteering.remove(at: index) + draft = [pending.draft, draft].filter { !$0.isEmpty }.joined(separator: "\n\n") + for chip in pending.attachments { attach(chip) } + } + + /// Reversed because each entry is put back *above* the draft, so walking + /// the queue backwards is what leaves it in the order it was typed. + private func returnAllSteeringToDraft() { + for pending in pendingSteering.reversed() { returnSteeringToDraft(pending.id) } } /// Owns the approval queue. @@ -619,7 +671,10 @@ final class ConversationModel: Identifiable { case .runState(let state): runtimeState = state refreshRunState() - if state.isTerminal { runDidFinish?() } + if state.isTerminal { + returnAllSteeringToDraft() + runDidFinish?() + } // Run and turn boundaries are for readers that group by turn. The // conversation draws a flat timeline, so it has nothing to do with // them — but they are listed rather than swept into a `default` so @@ -647,6 +702,16 @@ final class ConversationModel: Identifiable { )) } refreshRunState() + // A queued turn has entered the model's context, so now it is part of + // the conversation — in both lists, at the position the run put it, and + // no longer in the queue the composer is drawing. + case .steeringDelivered(let message): + pendingSteering.removeAll { $0.id == message.id } + messages.append(ChatMessage( + id: message.id, role: .user, text: message.text, + createdAt: message.createdAt + )) + transcript.append(message) case .messageFinished(let message): if let index = messages.firstIndex(where: { $0.id == message.id }) { messages[index].text = message.text diff --git a/Termind/Features/AI/ConversationTranscriptView.swift b/Termind/Features/AI/ConversationTranscriptView.swift index 13f7a6bb..71c42baf 100644 --- a/Termind/Features/AI/ConversationTranscriptView.swift +++ b/Termind/Features/AI/ConversationTranscriptView.swift @@ -14,13 +14,21 @@ struct ConversationView: View { var body: some View { VStack(spacing: 0) { transcript + // Each region rules itself off from whatever is above it, so every + // pair gets exactly one hairline. Ruling *below* instead, which is + // what these did, put two adjacent lines under any card that was + // showing — the card's own and the composer's. if let approval = conversation.pendingApproval { - AgentApprovalCard(request: approval, conversation: conversation) Divider() + AgentApprovalCard(request: approval, conversation: conversation) } if let input = conversation.pendingUserInput { - AgentUserInputCard(request: input, conversation: conversation) Divider() + AgentUserInputCard(request: input, conversation: conversation) + } + if !conversation.pendingSteering.isEmpty { + Divider() + SteeringQueueView(conversation: conversation) } Divider() ComposerView(conversation: conversation, sessionContext: sessionContext) @@ -439,3 +447,91 @@ private struct AgentUserInputCard: View { } } } + +// MARK: - Steering + +/// What the user has said to a busy agent that it has not been told yet. +/// +/// Deliberately outside the transcript. These are not turns of the conversation +/// until the run delivers them, and drawing them as bubbles would put words in +/// a place the model never read them. The strip empties itself: each row leaves +/// as its message enters the transcript proper. +private struct SteeringQueueView: View { + let conversation: ConversationModel + + /// The rows' own height, because a `ScrollView` will not report one — left + /// alone it takes every point it is offered, and the strip would stand at + /// its ceiling with one line in it. Same problem, and same answer, as the + /// draft editor's height in `ComposerView`. + @State private var rowsHeight: CGFloat = 0 + + /// The wait has two different causes, and only one of them resolves on its + /// own. While a card above is holding the run, saying "next step" would be + /// a promise nothing is working towards. + private var caption: LocalizedStringKey { + switch conversation.runState { + case .waitingForApproval, .waitingForUser: "Steering — held until you answer above" + default: "Steering — delivered at the next step" + } + } + + var body: some View { + VStack(alignment: .leading, spacing: Metrics.Space.xs) { + Label(caption, systemImage: "arrow.turn.down.right") + .font(.caption) + .foregroundStyle(.secondary) + + ScrollView { + VStack(alignment: .leading, spacing: Metrics.Space.xxs) { + ForEach(conversation.pendingSteering) { pending in + row(pending) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { rowsHeight = $0 } + } + .frame(height: min(rowsHeight, Metrics.Chat.steeringQueueMaxHeight)) + .scrollBounceBehavior(.basedOnSize) + } + .padding(.horizontal, Metrics.Space.l) + .padding(.vertical, Metrics.Space.s) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.25)) + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("Queued messages")) + } + + /// What was typed, not what will be sent: `message.text` carries the + /// attachment blocks folded in for the model, and a row of markup is not a + /// reminder of anything. + private func row(_ pending: PendingSteeringMessage) -> some View { + HStack(spacing: Metrics.Space.s) { + Text(pending.draft) + .font(.callout) + .lineLimit(1) + .truncationMode(.tail) + + if !pending.attachments.isEmpty { + Label("\(pending.attachments.count)", systemImage: "paperclip") + .font(.caption2) + .foregroundStyle(.secondary) + .labelStyle(.titleAndIcon) + } + + Spacer(minLength: Metrics.Space.s) + + Button { + conversation.returnSteeringToDraft(pending.id) + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .frame(width: Metrics.Sidebar.rowIcon, height: Metrics.Sidebar.rowIcon) + .contentShape(.rect) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(Text("Take this back and edit it")) + .accessibilityLabel(Text("Unqueue message")) + } + } +} diff --git a/Termind/Features/AI/ConversationViews.swift b/Termind/Features/AI/ConversationViews.swift index ca8c128f..b6b82c4a 100644 --- a/Termind/Features/AI/ConversationViews.swift +++ b/Termind/Features/AI/ConversationViews.swift @@ -263,6 +263,17 @@ struct ComposerView: View { sendDraft() return .handled } + // The Stop button gives its slot to Send as soon as the draft + // has a character in it — which is exactly when this field has + // focus. Without this, typing would make a run unstoppable + // until the draft was cleared again. + .onKeyPress(KeyEquivalent("."), phases: .down) { press in + guard conversation.isStreaming, press.modifiers.contains(.command) else { + return .ignored + } + conversation.stop() + return .handled + } } .frame( height: min( @@ -277,6 +288,18 @@ struct ComposerView: View { /// when the field gains content. private static let editorTextInset: CGFloat = 5 + private var trimmedDraft: String { + conversation.draft.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Stop and Send share one slot rather than standing side by side. + /// + /// Drawn together they read as a pair of equals, and the primary action + /// moved under the pointer the moment the draft gained a character. With a + /// draft to send, Send is what the button is for; with nothing to send, + /// stopping is the only thing left to do. + private var showsStopButton: Bool { conversation.isStreaming && trimmedDraft.isEmpty } + private var standardControls: some View { HStack(spacing: Metrics.Space.s) { ViewThatFits(in: .horizontal) { @@ -286,31 +309,35 @@ struct ComposerView: View { .frame(maxWidth: .infinity) transcribeButton - if conversation.isStreaming { - sendButton( - enabled: !conversation.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - action: sendDraft - ) - .help(Text("Queue message for the running agent")) - Button(action: conversation.stop) { - Label("Stop", systemImage: "stop.circle.fill") - } - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .font(.system(size: 26, weight: .semibold)) - .foregroundStyle(.red) - .frame(width: 32, height: 32) - .help(Text("Stop current run")) + if showsStopButton { + stopButton } else { sendButton( - enabled: !conversation.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + enabled: !trimmedDraft.isEmpty, + help: conversation.isStreaming + ? "Queue for the running agent — ⏎" : "Send — ⌘⏎", action: sendDraft ) } } + .animation(Motion.panel(reduceMotion: reduceMotion), value: showsStopButton) .frame(maxWidth: .infinity, minHeight: 32, maxHeight: 32) } + private var stopButton: some View { + Button(action: conversation.stop) { + Label("Stop", systemImage: "stop.circle.fill") + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .keyboardShortcut(".", modifiers: .command) + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(.red) + .frame(width: 32, height: 32) + .contentShape(.circle) + .help(Text("Stop current run — ⌘.")) + } + private var transcribeButton: some View { Button(action: beginTranscription) { Group { @@ -397,7 +424,11 @@ struct ComposerView: View { } } - private func sendButton(enabled: Bool, action: @escaping () -> Void) -> some View { + private func sendButton( + enabled: Bool, + help: LocalizedStringKey = "Send — ⌘⏎", + action: @escaping () -> Void + ) -> some View { Button(action: action) { Label("Send", systemImage: "arrow.up.circle.fill") } @@ -407,7 +438,7 @@ struct ComposerView: View { .frame(width: 32, height: 32) .contentShape(.circle) .disabled(!enabled) - .help(Text("Send — ⌘⏎")) + .help(Text(help)) } private func circularControl( diff --git a/TermindTests/AgentRuntimeTests.swift b/TermindTests/AgentRuntimeTests.swift index 16ee67e3..05808d9d 100644 --- a/TermindTests/AgentRuntimeTests.swift +++ b/TermindTests/AgentRuntimeTests.swift @@ -79,6 +79,33 @@ private struct CapturingAgentModel: AgentModelStreaming { } } +/// Holds its first response open until released, and never asks for a tool. +/// +/// The gate has to be inside the *model* rather than inside a tool for this to +/// mean anything: the case under test is a turn that ends the run all by +/// itself, which is precisely the turn with no tool to hold open. +private struct GatedAgentModel: AgentModelStreaming { + let requests: RequestRecorder + let gate: AsyncGate + let counter = ScriptedAgentModel.Counter() + + func stream(_ request: AgentModelRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + await requests.append(request) + if await counter.next() == 0 { + continuation.yield(.textDelta("looking into it")) + await gate.beginAndWait() + } else { + continuation.yield(.textDelta("done")) + } + continuation.yield(.finished(.completed)) + continuation.finish() + } + } + } +} + private struct RecordingScriptedAgentModel: AgentModelStreaming { let turns: [[AgentModelStreamEvent]] let requests: RequestRecorder @@ -222,6 +249,7 @@ struct AgentRuntimeTests { func steeringJoinsTheTranscriptAtTheNextModelBoundary() async { let requests = RequestRecorder() let gate = AsyncGate() + let events = EventRecorder() let model = CapturingAgentModel(requests: requests) let tool = AnyAgentTool(descriptor: AgentToolDescriptor( name: "wait", summary: "wait", @@ -238,10 +266,11 @@ struct AgentRuntimeTests { let stream = await runtime.start(AgentRunRequest( conversationID: UUID(), prompt: "start", permissionMode: .askForApproval )) - let consumer = Task { for await _ in stream {} } + let consumer = Task { for await event in stream { await events.append(event) } } await gate.waitUntilStarted() - await runtime.steer("also inspect logs") + let steered = AgentTranscriptMessage(role: .user, text: "also inspect logs") + #expect(await runtime.steer(steered)) await gate.release() await consumer.value @@ -250,6 +279,12 @@ struct AgentRuntimeTests { #expect(captured.last?.messages.contains { $0.role == .user && $0.text == "also inspect logs" } == true) + // Announced by identity, so a reader can retire the queued row it drew + // without comparing one piece of text against another. + #expect(await events.values.contains { event in + guard case .steeringDelivered(let message) = event else { return false } + return message.id == steered.id && message.text == steered.text + }) let providerItems = captured.last?.messages .first(where: { $0.role == .assistant })?.providerItems #expect(providerItems?.count == 1) @@ -259,6 +294,68 @@ struct AgentRuntimeTests { ) } + /// The regression that made steering pointless: a reply with nothing to run + /// ended the run on the spot, so anything typed while it was being written + /// was left on screen as a turn the model was never shown. + @Test + func steeringKeepsARunAliveThroughATurnThatAsksForNoTools() async { + let requests = RequestRecorder() + let gate = AsyncGate() + let repository = InMemoryAgentRunRepository() + let runtime = AgentRuntime( + model: GatedAgentModel(requests: requests, gate: gate), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository + ) + var runID: UUID? + var states: [AgentRunState] = [] + let stream = await runtime.start(AgentRunRequest( + conversationID: UUID(), prompt: "start", permissionMode: .askForApproval + )) + let consumer = Task { + for await event in stream { + if case .runState(let state) = event { states.append(state) } + runID = runID ?? event.associatedRunID + } + return (runID, states) + } + + await gate.waitUntilStarted() + #expect(await runtime.steer(AgentTranscriptMessage(role: .user, text: "one more thing"))) + await gate.release() + let (id, observed) = await consumer.value + + let captured = await requests.values + #expect(captured.count == 2, "the queued message has to buy another turn") + #expect(captured.last?.messages.contains { $0.text == "one more thing" } == true) + #expect(observed.contains(.completed)) + let saved = await repository.snapshot(try! #require(id))?.messages + #expect(saved?.contains { $0.text == "one more thing" } == true) + } + + /// A queue nobody will drain is worse than a refusal: the caller still has + /// the words and can put them back in front of the user. + @Test + func steeringIsRefusedOnceThereIsNoRunLeftToDeliverIt() async { + let repository = InMemoryAgentRunRepository() + let runtime = AgentRuntime( + model: ScriptedAgentModel(turns: [[.textDelta("done"), .finished(.completed)]]), + tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository + ) + var runID: UUID? + for await event in await runtime.start(AgentRunRequest( + conversationID: UUID(), prompt: "start", permissionMode: .askForApproval + )) { + runID = runID ?? event.associatedRunID + } + + #expect(await runtime.steer(AgentTranscriptMessage(role: .user, text: "too late")) == false) + let saved = await repository.snapshot(try! #require(runID))?.messages + #expect(saved?.contains { $0.text == "too late" } == false) + } + @Test func schemaFailureHappensBeforeApprovalAndExecution() async { let model = ScriptedAgentModel(turns: [ diff --git a/TermindTests/AgentSteeringTests.swift b/TermindTests/AgentSteeringTests.swift new file mode 100644 index 00000000..dff33cf8 --- /dev/null +++ b/TermindTests/AgentSteeringTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing + +@testable import Termind + +/// A run that never ends on its own, so a conversation stays steerable for as +/// long as a test needs it to be. +private struct HangingAgentModel: AgentModelStreaming { + func stream( + _ request: AgentModelRequest + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + continuation.yield(.textDelta("working")) + try? await Task.sleep(for: .seconds(30)) + continuation.finish() + } + } + } +} + +/// What the conversation does with a message typed at a working agent. +/// +/// The runtime's half of steering is covered in `AgentRuntimeTests`; this is the +/// half the user sees — that the words leave the composer without pretending to +/// be a turn, that they become one at the moment the run actually takes them, +/// and that a run which ends first gives them back instead of eating them. +@MainActor +@Suite +struct AgentSteeringTests { + + @Test + func aQueuedDraftLeavesTheComposerWithoutEnteringEitherTranscript() { + let conversation = ConversationModel() + startRun(conversation) + conversation.attach(ContextChip(kind: .output, label: "Last 20 lines", content: "boom")) + conversation.draft = " also tail the error log " + + conversation.submitSteeringDraft() + + #expect(conversation.draft.isEmpty) + // Consumed by the message they were attached to, exactly as a turn that + // starts a run consumes them. + #expect(conversation.attachments.isEmpty) + #expect(conversation.messages.isEmpty) + #expect(conversation.transcript.isEmpty) + + #expect(conversation.pendingSteering.count == 1) + let pending = conversation.pendingSteering[0] + #expect(pending.draft == "also tail the error log") + #expect(pending.attachments.count == 1) + // The payload travels with it. A chip that only labelled something + // would disclose nothing to the model. + #expect(pending.message.text.contains("")) + #expect(pending.message.text.contains("boom")) + } + + @Test + func deliveryMovesAQueuedMessageIntoBothTranscripts() { + let conversation = ConversationModel() + startRun(conversation) + conversation.draft = "check nginx too" + conversation.submitSteeringDraft() + let queued = try! #require(conversation.pendingSteering.first).message + + // Restamped by the run, which is what puts the bubble after the reply + // the user was reading when they typed it rather than before it. + var delivered = queued + delivered.createdAt = .now + conversation.handle(.steeringDelivered(delivered)) + + #expect(conversation.pendingSteering.isEmpty) + #expect(conversation.messages.count == 1) + #expect(conversation.messages[0].role == .user) + #expect(conversation.messages[0].id == queued.id) + #expect(conversation.messages[0].text == "check nginx too") + #expect(conversation.messages[0].createdAt == delivered.createdAt) + #expect(conversation.transcript == [delivered]) + } + + @Test + func stoppingARunHandsUndeliveredWordsBackToTheComposer() { + let conversation = ConversationModel() + startRun(conversation) + conversation.attach(ContextChip(kind: .file, label: "nginx.conf", content: "server {}")) + conversation.draft = "first" + conversation.submitSteeringDraft() + conversation.draft = "second" + conversation.submitSteeringDraft() + conversation.draft = "third, still being typed" + + conversation.stop() + + #expect(conversation.pendingSteering.isEmpty) + // In the order they were typed, with whatever was still in the box + // last, so nothing has to be reassembled by hand. + #expect(conversation.draft == "first\n\nsecond\n\nthird, still being typed") + // The composer gets the draft back, not the folded payload. + #expect(!conversation.draft.contains("