Send what the user typed while the agent was working
Typing at a running agent put a bubble on screen and, more often than not, did nothing else. The loop returned `complete` the moment an assistant turn came back with no tool calls, and the steering queue was only drained at the top of the *next* iteration — so anything typed while the final reply was being written was dropped on the floor, sitting in the transcript as a turn the model was never shown. Pi does not have this bug because its inner loop runs `while (hasMoreToolCalls || pendingMessages.length > 0)`: a queued message keeps the loop alive even when the model asked for nothing. That is the rule adopted here, and it is the whole fix — the guard now completes only when the queue is empty and otherwise continues, which buys the message a turn of its own. The second defect was quieter and worse. The bubble was appended to both `messages` and `transcript` at the keystroke, so the conversation ordered it *before* the assistant text that actually preceded it while the run's snapshot ordered it after. `transcript` is what `pendingPriorMessages` replays as the next run's history, so a steered conversation would later show the model a conversation that never happened. Nothing is written at the keystroke now. The queued message enters both lists on `steeringDelivered`, which the runtime emits at the moment it injects it, and its `createdAt` is restamped there — a message stamped while the previous turn was still streaming sorts above the reply the user was reading when they typed it, which is not where the model saw it. A queue is a promise, so it can be broken and the words have to survive that. `steer` returns a Bool rather than swallowing the message: `false` means there is no run left to deliver it and the caller still owns the text. The gate is `acceptsSteering` rather than `runTask != nil`, because `complete` awaits its final persist before the run ends and an actor is reentrant at every await — a message accepted in that window would land in a queue nobody drains again. It is cleared as the first statement of every terminating path, which makes the window empty rather than narrow, and set in `start` rather than inside the run task, because the first drain happens ahead of the first provider request and something typed that early should still be delivered. Whatever is undelivered when a run ends — stopped, failed, or refused in that race — goes back into the composer with its attachments, joined above whatever has been typed since. Returning the whole queue at once rather than one entry per refused hop is also what keeps the words in the order they were written. What is queued is drawn as a queue, not as a turn. A strip above the composer lists each pending message with an ✕ that hands it back, and it empties itself as each row enters the transcript proper. The caption changes to "held until you answer above" while an approval or a question card is holding the run, because in that state nothing is working towards the next step and promising one would be a lie. Rows show what was typed, not the composed payload, which carries the attachment blocks folded in for the model. Its height is measured from its rows and bounded, because a bare `ScrollView` is greedy and would stand at its 88pt ceiling with one line in it — the same problem, and the same answer, as the draft editor's height. Stop and Send no longer stand 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; they share one slot now, Stop when there is nothing to send and Send otherwise. That leaves Stop without a target while typing, so `⌘.` reaches it from the draft field as well as from the button. And each region above the composer now rules itself off from what is above it instead of below it: ruling below meant any visible card was followed by two adjacent hairlines, its own and the composer's. Two things from Pi are deliberately left out. There is no follow-up queue — the second queue, delivered only once the agent would otherwise stop — because Enter while running means steering, as it does in Pi's own TUI. And there is no `QueueMode`: Pi drains one message per boundary by default, where draining the whole queue costs one round trip instead of several for the common case of two hurried messages. The delivered bubble is also left unmarked, since once injected it is an ordinary user turn, and `ChatMessage` is rebuilt from stored transcript rows on relaunch, so any flag would quietly disappear on restart. The regression test was checked against the unfixed loop rather than merely observed to pass: reverting the guard makes it fail with one provider request instead of two, and with the queued message absent from both the second request and the saved snapshot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
da3082aff8
commit
61fc841627
8 files changed
+567
-42
No files matched your search
@@ -44,6 +44,14 @@ actor AgentRuntime {
|
||||
private let channel: AgentEventChannel
|
||||
private var runTask: Task<Void, Never>?
|
||||
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<AgentEvent> {
|
||||
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..<configuration.maxTurns {
|
||||
try Task.checkCancellation()
|
||||
if !steering.isEmpty {
|
||||
snapshot.messages.append(contentsOf: steering)
|
||||
steering.removeAll()
|
||||
}
|
||||
channel.emit(.turnStarted(index: index))
|
||||
if !steering.isEmpty {
|
||||
snapshot.messages.append(contentsOf: deliverSteering())
|
||||
await persist(snapshot)
|
||||
}
|
||||
let turn = try await driver.run(pipeline(AgentModelContext(
|
||||
systemPrompt: request.systemPrompt,
|
||||
messages: snapshot.messages,
|
||||
@@ -176,7 +199,14 @@ actor AgentRuntime {
|
||||
|
||||
if turn.reason == .error { throw AgentRuntimeError.providerFailed }
|
||||
guard !turn.message.toolCalls.isEmpty else {
|
||||
return await complete(snapshot, runID: runID)
|
||||
// A reply with nothing to run used to end the run right
|
||||
// here, which threw away whatever the user had typed while
|
||||
// it was being written — on screen as a turn the model was
|
||||
// never shown. Pi keeps its inner loop alive while messages
|
||||
// are pending for exactly this reason; the next iteration
|
||||
// delivers them and asks again.
|
||||
if steering.isEmpty { return await complete(snapshot, runID: runID) }
|
||||
continue
|
||||
}
|
||||
|
||||
// Refusals still produce one result per call, then throw. An
|
||||
@@ -222,11 +252,13 @@ actor AgentRuntime {
|
||||
}
|
||||
throw AgentRuntimeError.turnBudgetExceeded
|
||||
} catch is CancellationError {
|
||||
acceptsSteering = false
|
||||
snapshot.state = .cancelled
|
||||
snapshot.finishedAt = .now
|
||||
channel.emit(.runState(.cancelled))
|
||||
await persist(snapshot)
|
||||
} catch {
|
||||
acceptsSteering = false
|
||||
snapshot.state = .failed
|
||||
snapshot.failure = error.localizedDescription
|
||||
snapshot.finishedAt = .now
|
||||
@@ -237,7 +269,26 @@ actor AgentRuntime {
|
||||
finish(runID: runID)
|
||||
}
|
||||
|
||||
/// Empties the queue into the turn that is about to start, and says so.
|
||||
///
|
||||
/// The timestamp is rewritten here rather than kept from the keystroke that
|
||||
/// queued it. A conversation orders assistant turns and tool cards by time,
|
||||
/// and a message stamped while the previous turn was still streaming sorts
|
||||
/// above the reply the user was reading when they typed it — which is not
|
||||
/// where the model saw it.
|
||||
private func deliverSteering() -> [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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<UUID> = []
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AgentModelStreamEvent, any Error> {
|
||||
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: [
|
||||
|
||||
@@ -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<AgentModelStreamEvent, any Error> {
|
||||
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("<output label=\"Last 20 lines\">"))
|
||||
#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("<file"))
|
||||
#expect(conversation.attachments.count == 1)
|
||||
#expect(conversation.attachments[0].label == "nginx.conf")
|
||||
}
|
||||
|
||||
@Test
|
||||
func aRunThatFailsReturnsTheQueueAsWell() {
|
||||
let conversation = ConversationModel()
|
||||
startRun(conversation)
|
||||
conversation.draft = "and restart it"
|
||||
conversation.submitSteeringDraft()
|
||||
|
||||
conversation.handle(.runState(.failed))
|
||||
|
||||
#expect(conversation.pendingSteering.isEmpty)
|
||||
#expect(conversation.draft == "and restart it")
|
||||
}
|
||||
|
||||
@Test
|
||||
func takingOneRowBackLeavesTheRestQueued() {
|
||||
let conversation = ConversationModel()
|
||||
startRun(conversation)
|
||||
conversation.draft = "first"
|
||||
conversation.submitSteeringDraft()
|
||||
conversation.draft = "second"
|
||||
conversation.submitSteeringDraft()
|
||||
|
||||
conversation.returnSteeringToDraft(conversation.pendingSteering[0].id)
|
||||
|
||||
#expect(conversation.draft == "first")
|
||||
#expect(conversation.pendingSteering.count == 1)
|
||||
#expect(conversation.pendingSteering[0].draft == "second")
|
||||
}
|
||||
|
||||
@Test
|
||||
func anEmptyDraftQueuesNothing() {
|
||||
let conversation = ConversationModel()
|
||||
startRun(conversation)
|
||||
conversation.draft = " \n "
|
||||
|
||||
conversation.submitSteeringDraft()
|
||||
|
||||
#expect(conversation.pendingSteering.isEmpty)
|
||||
#expect(conversation.draft == " \n ")
|
||||
}
|
||||
|
||||
/// Nothing is queued without a run to hand it to — an idle composer sends.
|
||||
@Test
|
||||
func thereIsNothingToSteerWithoutARun() {
|
||||
let conversation = ConversationModel()
|
||||
conversation.draft = "hello"
|
||||
|
||||
conversation.submitSteeringDraft()
|
||||
|
||||
#expect(conversation.pendingSteering.isEmpty)
|
||||
#expect(conversation.draft == "hello")
|
||||
}
|
||||
|
||||
private func startRun(_ conversation: ConversationModel) {
|
||||
conversation.start(
|
||||
runtime: AgentRuntime(
|
||||
model: HangingAgentModel(), tools: [],
|
||||
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow })
|
||||
),
|
||||
request: AgentRunRequest(
|
||||
conversationID: conversation.id, prompt: "start",
|
||||
permissionMode: .askForApproval
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user