From 7084145c474d5bdffc9d1226bdaa0717d659f55f Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Wed, 30 Sep 2026 14:12:16 +0800 Subject: [PATCH] feat(assistant): remove todo tool and task list UI --- Frameworks/Resources/Localizable.xcstrings | 86 --------- .../Agent/ConversationModel.swift | 12 +- .../Strings/Assistant.swift | 16 -- .../Agent/AssistantToolRegistry.swift | 3 +- .../AI/MobileAssistantConversation.swift | 174 ++--------------- TFITests/MobileAssistantRenderingTests.swift | 8 - TFITests/MobileAssistantTests.swift | 2 +- TFM/Design/DesignTokens.swift | 20 -- .../AI/ConversationTranscriptView.swift | 175 ------------------ .../AgentBuiltInToolAvailabilityTests.swift | 6 +- TFMTests/AgentPlanModeTests.swift | 101 ---------- 11 files changed, 19 insertions(+), 584 deletions(-) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 2d1d19bb..6ce6228d 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -1134,29 +1134,6 @@ } } }, - "%lld of %lld tasks done" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld of %2$lld tasks done" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已完成%1$lld/%2$lld个任务" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已完成%1$lld/%2$lld個任務" - } - } - } - }, "%lld panes" : { "extractionState" : "manual", "localizations" : { @@ -26516,29 +26493,6 @@ } } }, - "Step %lld / %lld" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Step %1$lld / %2$lld" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "步骤%1$lld/%2$lld" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "步驟%1$lld/%2$lld" - } - } - } - }, "Still running after %llds. The output above is what it has printed so far. Call ssh_wait with this handle for the rest; leaving it unasked-about abandons it." : { "extractionState" : "manual", "localizations" : { @@ -27255,46 +27209,6 @@ } } }, - "Tasks" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "任务" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "任務" - } - } - } - }, - "Tasks, %lld of %lld done" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Tasks, %1$lld of %2$lld done" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "任务,已完成%1$lld/%2$lld" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "任務,已完成%1$lld/%2$lld" - } - } - } - }, "Teal" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index bf8255ae..6f0d12cb 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -919,12 +919,6 @@ public final class ConversationModel: Identifiable { /// that run releases ownership. public var effectiveIsPlanning: Bool { activeRunOwner?.request.isPlanning ?? isPlanning } - /// The current task list, whole. Replaced rather than patched — see - /// `AgentTask`. - public private(set) var tasks: [AgentTask] = [] - - public var completedTaskCount: Int { tasks.filter { $0.status == .completed }.count } - /// How many characters of tool schema the last run sent. /// /// Recorded when a run starts rather than derived on demand: the registry is @@ -1363,7 +1357,6 @@ public final class ConversationModel: Identifiable { return removedMessageIDs.contains(boundary) } compactions = compactionRecords.map(AgentCompactionMark.init) - tasks.removeAll() usage = nil contextEstimateTracker.setMeasured(nil) publishContextEstimateImmediately() @@ -3284,8 +3277,9 @@ public final class ConversationModel: Identifiable { } case .planPresented(let plan): plans.append(AgentPlanCard(plan: plan)) - case .tasksUpdated(let updated): - tasks = updated + case .tasksUpdated: + // AgentKit supports task lists, but Assistant does not register that tool. + break case .failed(let reason): guard let activeRunID, var run = runSummaries[activeRunID] else { break } run.state = .failed diff --git a/Frameworks/TermindPresentation/Strings/Assistant.swift b/Frameworks/TermindPresentation/Strings/Assistant.swift index f7d71a26..4ff18015 100644 --- a/Frameworks/TermindPresentation/Strings/Assistant.swift +++ b/Frameworks/TermindPresentation/Strings/Assistant.swift @@ -295,10 +295,6 @@ extension AppStrings { "No models configured" } - public static func ofTasksDone(completedTaskCount: Int, count: Int) -> LocalizedStringResource { - "\(completedTaskCount) of \(count) tasks done" - } - public static var onlyAskForActionsDetectedAsPotentiallyUnsafe: LocalizedStringResource { "Only ask for actions detected as potentially unsafe" } @@ -463,10 +459,6 @@ extension AppStrings { "Steering" } - public static func step(completed: Int, total: Int) -> LocalizedStringResource { - "Step \(completed) / \(total)" - } - public static var stopCurrentRun: LocalizedStringResource { "Stop current run — ⌘." } @@ -483,14 +475,6 @@ extension AppStrings { "Take this back and edit it" } - public static var tasks: LocalizedStringResource { - "Tasks" - } - - public static func tasksOfDone(completed: Int, total: Int) -> LocalizedStringResource { - "Tasks, \(completed) of \(total) done" - } - public static var thinkingProgress: LocalizedStringResource { "Thinking…" } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift index 21dc3322..c3bb9b25 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift @@ -35,7 +35,7 @@ public enum AssistantToolRegistry { // and `scratch_fetch` read the same web the same way and differ only in // where the page ends up. let web = ScrubberWebClient() - // Both write through the one ordered channel the rest of the run + // Plans write through the ordered channel the rest of the run // uses, rather than reaching into the conversation directly: a tool // that touched `@Observable` state from a task group would be // mutating the UI from off the main actor. @@ -52,7 +52,6 @@ public enum AssistantToolRegistry { web: web, search: localSearch, secretConsumerTools: [TermindAgentPolicy.sshExecToolName], - tasks: AgentTaskList { channel.emit(.tasksUpdated($0)) }, plans: AgentPlanRecorder { channel.emit(.planPresented($0)) }, skills: skillCatalog, chatModels: chatModels, chats: chats, chatSource: chatSource, diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index c2697476..46e8141f 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -283,148 +283,6 @@ struct MobileAssistantEditMessageTextEditor: View { } } -private struct MobileAssistantTaskCapsule: View { - let conversation: ConversationModel - - @State private var isExpanded = false - @State private var rowsHeight: CGFloat = 0 - @State private var overlayHeight: CGFloat = 0 - @State private var availableWidth: CGFloat = 320 - - private var isComplete: Bool { - conversation.completedTaskCount == conversation.tasks.count - } - - var body: some View { - capsule - .frame(maxWidth: .infinity) - .overlay(alignment: .top) { - if isExpanded { - VStack(spacing: 0) { - list - Color.clear.frame(height: 4) - } - .onGeometryChange(for: CGFloat.self) { - $0.size.height - } action: { - overlayHeight = $0 - } - .offset(y: -overlayHeight) - .transition(.opacity) - } - } - .onGeometryChange(for: CGFloat.self) { - $0.size.width - } action: { - availableWidth = $0 - } - .animation(.easeOut(duration: 0.12), value: isExpanded) - } - - private var capsule: some View { - Button { - isExpanded.toggle() - } label: { - HStack(spacing: 4) { - ProgressRing( - share: Double(conversation.completedTaskCount) - / Double(max(conversation.tasks.count, 1)), - tint: isComplete ? .green : .accentColor, - diameter: 10, - lineWidth: 1.7 - ) - Text( - AppStrings.Assistant.step( - completed: conversation.completedTaskCount, total: conversation.tasks.count) - ) - .font(.caption2.monospacedDigit()) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 3) - .background(.regularMaterial, in: Capsule()) - .overlay { - Capsule().stroke(.primary.opacity(0.08), lineWidth: 1).allowsHitTesting(false) - } - .contentShape(.capsule) - } - .buttonStyle(.plain) - .frame(minHeight: 44) - .accessibilityLabel( - Text( - AppStrings.Assistant.tasksOfDone( - completed: conversation.completedTaskCount, total: conversation.tasks.count)) - ) - .accessibilityValue(isExpanded ? Text(AppStrings.Assistant.expanded) : Text(AppStrings.Assistant.collapsed)) - } - - private var list: some View { - ScrollView { - VStack(alignment: .leading, spacing: 2) { - ForEach(conversation.tasks) { task in - row(task) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .onGeometryChange(for: CGFloat.self) { - $0.size.height - } action: { - rowsHeight = $0 - } - } - .frame(height: min(rowsHeight, 160)) - .scrollBounceBehavior(.basedOnSize) - .padding(12) - .frame(width: min(availableWidth, 320)) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(.primary.opacity(0.08), lineWidth: 1) - .allowsHitTesting(false) - } - .shadow(color: .black.opacity(0.08), radius: 16, y: 6) - .accessibilityElement(children: .contain) - .accessibilityLabel(Text(AppStrings.Assistant.tasks)) - } - - private func row(_ task: AgentTask) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Image(systemName: symbol(task.status)) - .font(.caption) - .foregroundStyle(tint(task.status)) - .frame(width: 20) - Text(task.title) - .font(.callout) - .foregroundStyle( - task.status == .completed - ? Color(uiColor: .secondaryLabel) : Color(uiColor: .label) - ) - .strikethrough( - task.status == .completed, - color: Color(uiColor: .secondaryLabel) - ) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - private func symbol(_ status: AgentTask.Status) -> String { - switch status { - case .pending: "circle" - case .inProgress: "circle.lefthalf.filled" - case .completed: "checkmark.circle.fill" - } - } - - private func tint(_ status: AgentTask.Status) -> Color { - switch status { - case .pending: .secondary - case .inProgress: .accentColor - case .completed: .green - } - } -} - private struct MobileAssistantSteeringQueue: View { let conversation: ConversationModel @@ -624,7 +482,6 @@ struct MobileAssistantConversation: View { @ViewBuilder private var bottomSurface: some View { bottomSurfaceContent - .padding(.top, !conversation.tasks.isEmpty && acceptsAttachmentDrops ? 44 : 0) .padding(12) .frame(maxWidth: 840) .frame(maxWidth: .infinity) @@ -648,27 +505,18 @@ struct MobileAssistantConversation: View { } private var regularInteractionSurface: some View { - ZStack(alignment: .top) { - VStack(spacing: 8) { - if !conversation.pendingSteering.isEmpty { - MobileAssistantSteeringQueue(conversation: conversation) - } - MobileAssistantComposer( - pro: app.pro, - model: model, - conversation: conversation, - focus: composerFocus, - focusRequest: composerFocusRequest, - focusRequestHandled: composerFocusRequestHandled - ) - } - .zIndex(20) - - if !conversation.tasks.isEmpty { - MobileAssistantTaskCapsule(conversation: conversation) - .offset(y: -44) - .zIndex(10) + VStack(spacing: 8) { + if !conversation.pendingSteering.isEmpty { + MobileAssistantSteeringQueue(conversation: conversation) } + MobileAssistantComposer( + pro: app.pro, + model: model, + conversation: conversation, + focus: composerFocus, + focusRequest: composerFocusRequest, + focusRequestHandled: composerFocusRequestHandled + ) } } diff --git a/TFITests/MobileAssistantRenderingTests.swift b/TFITests/MobileAssistantRenderingTests.swift index 658cb71b..30113576 100644 --- a/TFITests/MobileAssistantRenderingTests.swift +++ b/TFITests/MobileAssistantRenderingTests.swift @@ -234,14 +234,6 @@ struct MobileAssistantRenderingTests { conversation.respondToApproval(approvalRequest.id, allow: false) _ = await approval.value - conversation.handle( - .tasksUpdated([ - AgentTask( - id: "verify", - title: text("Verify the selected approach", "验证所选方案", "驗證所選方案"), - status: .inProgress - ) - ])) conversation.present( AgentUserInputRequest(questions: [ AgentUserQuestion( diff --git a/TFITests/MobileAssistantTests.swift b/TFITests/MobileAssistantTests.swift index 99e4a3d4..8d53897a 100644 --- a/TFITests/MobileAssistantTests.swift +++ b/TFITests/MobileAssistantTests.swift @@ -461,7 +461,7 @@ struct MobileAssistantTests { let names = Set(mobileDescriptors.map(\.name)) for name in [ "ssh_exec", "ssh_wait", "sftp_read", "sftp_write", "list_hosts", "get_metrics", - "scratch_pull", "scratch_push", "present_plan", "manage_tasks", "request_user_input", "request_user_secret", + "scratch_pull", "scratch_push", "present_plan", "request_user_input", "request_user_secret", "terminal_suggest", ] { #expect(names.contains(name), "Missing tool: \(name)") diff --git a/TFM/Design/DesignTokens.swift b/TFM/Design/DesignTokens.swift index 823a370e..a415fbb1 100644 --- a/TFM/Design/DesignTokens.swift +++ b/TFM/Design/DesignTokens.swift @@ -243,26 +243,6 @@ nonisolated enum Metrics { /// in the transcript from becoming the transcript. static let planPreviewHeight: CGFloat = 96 - /// How much of the task list is shown before it scrolls. - /// - /// Taller than the steering strip because this one is read rather than - /// glanced at — it is the answer to "what is left" — but still a ceiling - /// measured from the rows, so a three-item list is three items tall. - static let taskListMaxHeight: CGFloat = 160 - - /// How wide the hovered task list is. - /// - /// Narrow on purpose. It hangs off a capsule a few characters wide, and - /// a panel spanning the conversation would read as a new region rather - /// than as that capsule opening. Task titles are short phrases. - /// - /// Two values because an overlay is proposed its *parent's* size — the - /// capsule's, which is tiny — so this cannot be a `maxWidth` that adapts - /// on its own. The compact figure is what fits inside a 260pt sidebar - /// once the composer's own insets are taken off. - static let taskListWidth: CGFloat = 320 - static let compactTaskListWidth: CGFloat = 232 - } enum TransferCenter { diff --git a/TFM/Features/AI/ConversationTranscriptView.swift b/TFM/Features/AI/ConversationTranscriptView.swift index 67ca778d..e850c457 100644 --- a/TFM/Features/AI/ConversationTranscriptView.swift +++ b/TFM/Features/AI/ConversationTranscriptView.swift @@ -148,16 +148,6 @@ struct ConversationView: View { AgentApprovalCard(request: approval, conversation: conversation) } } else { - if !conversation.tasks.isEmpty { - chatColumn { - AgentTaskCapsuleView( - conversation: conversation, isCompact: isCompact - ) - } - // Below the composer's Slash overlay, above Steering and - // every ordinary surface in this interaction region. - .zIndex(50) - } if !conversation.pendingSteering.isEmpty { chatColumn { SteeringQueueView( @@ -368,171 +358,6 @@ private struct ConversationTranscriptSurface: View { } } -// MARK: - Tasks - -/// What the agent is working through, as one small capsule. -/// -/// Above the composer rather than in the transcript, and that difference is the -/// design. A plan is a document decided once, so it belongs where it happened. A -/// task list is live state rewritten every turn: one transcript row that -/// silently mutated would misdate itself, and one row per revision would bury -/// the conversation it describes. -/// -/// A capsule rather than a strip because at rest the only thing worth saying is -/// "4 / 9". It sits above every other thing stacked over the composer — the -/// approval card, a question, the steering queue — because it is the *state* of -/// the work, and those are all interruptions to it. -/// -/// The list opens on hover, as an overlay drawn upward over the transcript. -/// Inserting it into the stack instead would push the conversation every time -/// the pointer crossed the pill. -struct AgentTaskCapsuleView: View { - let conversation: ConversationModel - var isCompact = false - - /// Two hover flags, not one. The list is an overlay outside the capsule's - /// own frame, so hovering it does not keep the capsule's `onHover` true — - /// and with one flag the list would vanish the moment the pointer reached - /// for it. - @State private var isHoveringCapsule = false - @State private var isHoveringList = false - @State private var isPinned = false - /// The rows' own height. A `ScrollView` reports none — left alone it takes - /// every point it is offered. - @State private var rowsHeight: CGFloat = 0 - @State private var overlayHeight: CGFloat = 0 - - private var isComplete: Bool { - conversation.completedTaskCount == conversation.tasks.count - } - - private var isExpanded: Bool { isPinned || isHoveringCapsule || isHoveringList } - - var body: some View { - capsule - .overlay(alignment: .top) { - if isExpanded { - VStack(spacing: 0) { - list - // Keeps hover continuous while leaving a small visual - // gap between the floating list and its capsule. - Color.clear.frame(height: Metrics.Space.xs) - } - .onGeometryChange(for: CGFloat.self) { - $0.size.height - } action: { - overlayHeight = $0 - } - // The overlay's bottom meets the capsule's top. Using - // `.top` is important: bottom-aligning first and then - // subtracting the list height moved it upward twice. - .offset(y: -overlayHeight) - .onHover { isHoveringList = $0 } - .transition(.opacity) - } - } - .frame(maxWidth: .infinity) - .padding(.top, Metrics.Space.s) - .animation(.easeOut(duration: 0.12), value: isExpanded) - } - - private var capsule: some View { - Button { - // Hover is not available to everyone, and a pinned list is also how - // you read one without holding the pointer still. - isPinned.toggle() - } label: { - HStack(spacing: Metrics.Space.xs) { - ProgressRing( - share: Double(conversation.completedTaskCount) - / Double(max(conversation.tasks.count, 1)), - tint: isComplete ? .green : .accentColor, - diameter: 10, lineWidth: 1.7 - ) - Text( - AppStrings.Assistant.step( - completed: conversation.completedTaskCount, total: conversation.tasks.count) - ) - .font(.caption2.monospacedDigit()) - .foregroundStyle(.secondary) - } - .padding(.horizontal, Metrics.Space.m) - .padding(.vertical, 3) - .termindGlassCapsule(isInteractive: true) - .contentShape(.capsule) - } - .buttonStyle(.plain) - .onHover { isHoveringCapsule = $0 } - .help( - Text( - AppStrings.Assistant.ofTasksDone( - completedTaskCount: conversation.completedTaskCount, count: conversation.tasks.count)) - ) - .accessibilityLabel( - Text( - AppStrings.Assistant.tasksOfDone( - completed: conversation.completedTaskCount, total: conversation.tasks.count)) - ) - } - - private var list: some View { - ScrollView { - VStack(alignment: .leading, spacing: Metrics.Space.xxs) { - ForEach(conversation.tasks) { task in - row(task) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .onGeometryChange(for: CGFloat.self) { - $0.size.height - } action: { - rowsHeight = $0 - } - } - .frame(height: min(rowsHeight, Metrics.Chat.taskListMaxHeight)) - .scrollBounceBehavior(.basedOnSize) - .padding(Metrics.Space.l) - .frame( - width: isCompact - ? Metrics.Chat.compactTaskListWidth : Metrics.Chat.taskListWidth - ) - .termindGlassSurface(cornerRadius: Metrics.Chat.composerRadius) - .accessibilityElement(children: .contain) - .accessibilityLabel(Text(AppStrings.Assistant.tasks)) - } - - private func row(_ task: AgentTask) -> some View { - HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) { - Image(systemName: symbol(task.status)) - .font(.caption) - .foregroundStyle(tint(task.status)) - .frame(width: Metrics.Sidebar.rowIcon) - Text(task.title) - .font(.callout) - .foregroundStyle(task.status == .completed ? .secondary : .primary) - .strikethrough(task.status == .completed, color: .secondary) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - private func symbol(_ status: AgentTask.Status) -> String { - switch status { - case .pending: "circle" - case .inProgress: "circle.lefthalf.filled" - case .completed: "checkmark.circle.fill" - } - } - - private func tint(_ status: AgentTask.Status) -> Color { - switch status { - case .pending: .secondary - case .inProgress: .accentColor - case .completed: .green - } - } -} - // MARK: - Decisions /// Kept as an emphasised card on purpose. This is the one place in the diff --git a/TFMTests/AgentBuiltInToolAvailabilityTests.swift b/TFMTests/AgentBuiltInToolAvailabilityTests.swift index bcb318c7..e3cbdfd8 100644 --- a/TFMTests/AgentBuiltInToolAvailabilityTests.swift +++ b/TFMTests/AgentBuiltInToolAvailabilityTests.swift @@ -257,7 +257,7 @@ struct AgentBuiltInToolAvailabilityTests { let descriptors = AgentToolCatalog.registry( builtIn: .init( workspace: workspace, web: web, search: web, - tasks: AgentTaskList { _ in }, plans: AgentPlanRecorder { _ in } + plans: AgentPlanRecorder { _ in } ), additional: TermindAgentTools.tools( in: .init( @@ -289,7 +289,7 @@ struct AgentBuiltInToolAvailabilityTests { ) let everything = AgentBuiltInToolConfiguration( workspace: workspace, web: web, search: web, - tasks: AgentTaskList { _ in }, plans: AgentPlanRecorder { _ in }, + plans: AgentPlanRecorder { _ in }, skills: AgentSkillCatalog([ AgentSkill(name: "deploy", summary: "Deploy the app", body: "Steps") ]) @@ -302,7 +302,7 @@ struct AgentBuiltInToolAvailabilityTests { #expect(names.contains("scratch_write")) #expect(names.contains("request_user_secret")) for absent in [ - "fetch", "web_search", "scratch_fetch", "skill_read_file", "manage_tasks", + "fetch", "web_search", "scratch_fetch", "skill_read_file", "present_plan", ] { #expect(!names.contains(absent), "\(absent) survived group selection") diff --git a/TFMTests/AgentPlanModeTests.swift b/TFMTests/AgentPlanModeTests.swift index 519cffd3..a0847e03 100644 --- a/TFMTests/AgentPlanModeTests.swift +++ b/TFMTests/AgentPlanModeTests.swift @@ -360,37 +360,6 @@ struct AgentPlanModeTests { #expect(await ran.didRun) } - /// Blocked by name rather than by approval policy: `manage_tasks` changes - /// nothing, so the approval gate has no reason to stop it, and it still has no business - /// running before anything has been agreed to. - @Test - func theTaskListIsRefusedWhilePlanningEvenThoughItIsReadOnly() async { - let ran = RanFlag() - let events = await drain( - tools: [ - spyTool( - "manage_tasks", approvalPolicy: .approve, - availableIn: [.acting], ran: ran - ) - ], - turns: callingTurns(["manage_tasks"]), isPlanning: true - ) - - #expect(await ran.didRun == false) - #expect(results(events).first?.isError == true) - } - - @Test - func theTaskListRunsOutsidePlanMode() async { - let ran = RanFlag() - _ = await drain( - tools: [spyTool("manage_tasks", approvalPolicy: .approve, ran: ran)], - turns: callingTurns(["manage_tasks"]), isPlanning: false - ) - - #expect(await ran.didRun) - } - /// A partially-blocked turn must still answer every call it was asked for. /// An assistant turn with an unanswered tool call is rejected outright by /// every provider on the next request. @@ -512,74 +481,4 @@ struct AgentPlanModeTests { #expect(planned.messages[2].text != AgentPlanContractInjection.contract) #expect(planned.messages[2].text.contains("ssh_exec")) } - - // MARK: - Task arguments - - @Test - func aTaskListMustNameOneThingInProgress() throws { - let two = AgentToolInvocation( - runID: UUID(), - call: AgentToolCall( - id: "c", name: "manage_tasks", - arguments: .object([ - "tasks": .array([ - taskJSON("a", "Check disk", "in_progress"), - taskJSON("b", "Read config", "in_progress"), - ]) - ]) - ) - ) - - #expect(throws: AgentToolError.self) { - try Arguments(two).agentTasks() - } - } - - @Test - func aTaskListRejectsDuplicateIDs() throws { - let duplicated = AgentToolInvocation( - runID: UUID(), - call: AgentToolCall( - id: "c", name: "manage_tasks", - arguments: .object([ - "tasks": .array([ - taskJSON("a", "Check disk", "pending"), - taskJSON("a", "Read config", "pending"), - ]) - ]) - ) - ) - - #expect(throws: AgentToolError.self) { - try Arguments(duplicated).agentTasks() - } - } - - @Test - func aWellFormedTaskListParsesInOrder() throws { - let valid = AgentToolInvocation( - runID: UUID(), - call: AgentToolCall( - id: "c", name: "manage_tasks", - arguments: .object([ - "tasks": .array([ - taskJSON("a", "Check disk", "completed"), - taskJSON("b", "Read config", "in_progress"), - taskJSON("c", "Propose a fix", "pending"), - ]) - ]) - ) - ) - - let tasks = try Arguments(valid).agentTasks() - - #expect(tasks.map(\.id) == ["a", "b", "c"]) - #expect(tasks[1].status == .inProgress) - } - - private func taskJSON(_ id: String, _ title: String, _ status: String) -> AgentJSONValue { - .object([ - "id": .string(id), "title": .string(title), "status": .string(status), - ]) - } }