Merge nucleic/olive-coral-civet-pvqv into dev
This commit is contained in:
@@ -31,6 +31,8 @@ struct PanelHost: View {
|
||||
SubagentsPanel(session: session)
|
||||
case .nashViewer:
|
||||
NashViewerPanel(session: session)
|
||||
case .plan:
|
||||
PlanPanel(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,16 @@ final class PanelLayoutStore {
|
||||
add(.terminal, to: .bottom)
|
||||
}
|
||||
|
||||
/// Surface the current plan beside the chat. The approval popup uses this as its expanded
|
||||
/// reading surface; an already-placed Plan panel is left exactly where the user put it.
|
||||
func revealPlan() {
|
||||
let hasPlan = PanelSlot.allCases.contains {
|
||||
working.instances(in: $0).contains { $0.kind == .plan }
|
||||
}
|
||||
guard !hasPlan else { return }
|
||||
add(.plan, to: .right)
|
||||
}
|
||||
|
||||
/// Surface a Subagents panel — used by the orchestration card's "view all" action, when a
|
||||
/// delegation plan spawned more workers than the card lists inline. One already placed anywhere is
|
||||
/// left where it is (it lists the open chat's workers on its own); otherwise a fresh one is docked
|
||||
|
||||
@@ -9,6 +9,7 @@ enum PanelKind: String, Codable, CaseIterable, Identifiable, Hashable, Sendable
|
||||
case vmMonitor
|
||||
case subagents
|
||||
case nashViewer
|
||||
case plan
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -20,6 +21,7 @@ enum PanelKind: String, Codable, CaseIterable, Identifiable, Hashable, Sendable
|
||||
case .vmMonitor: "VM Monitor"
|
||||
case .subagents: "Subagents"
|
||||
case .nashViewer: "Nash Viewer"
|
||||
case .plan: "Plan"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ enum PanelKind: String, Codable, CaseIterable, Identifiable, Hashable, Sendable
|
||||
case .vmMonitor: "display"
|
||||
case .subagents: "person.2"
|
||||
case .nashViewer: "waveform"
|
||||
case .plan: "list.bullet.clipboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ struct PaneledChatView: View {
|
||||
subagentsSubtitle
|
||||
case .nashViewer:
|
||||
nashViewerSubtitle
|
||||
case .plan:
|
||||
PlanPanel.latestPlan(
|
||||
events: store.openTranscript, approvals: store.openApprovals) == nil
|
||||
? "No plan presented" : "Current proposal"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +113,8 @@ struct PaneledChatView: View {
|
||||
private func headerAccessory(for kind: PanelKind) -> AnyView {
|
||||
switch kind {
|
||||
case .editor: AnyView(EditorHeaderControls())
|
||||
case .fileExplorer, .terminal, .vmMonitor, .subagents, .nashViewer: AnyView(EmptyView())
|
||||
case .fileExplorer, .terminal, .vmMonitor, .subagents, .nashViewer, .plan:
|
||||
AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import NucleicCore
|
||||
import SwiftUI
|
||||
|
||||
/// A dedicated reading surface for the latest plan in the open chat. It follows the live pending
|
||||
/// approval first, then falls back to the newest plan tool call in the transcript, so the plan
|
||||
/// remains available after Accept / Revise / Deny removes the composer popup.
|
||||
struct PlanPanel: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
let session: Session?
|
||||
|
||||
private var plan: PlanReview.Plan? {
|
||||
guard session?.id == store.openSessionID else { return nil }
|
||||
return Self.latestPlan(events: store.openTranscript, approvals: store.openApprovals)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let plan {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
MarkdownText(markdown: plan.markdown, bodySize: 13)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let filePath = plan.filePath {
|
||||
Label(filePath, systemImage: "doc.text")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No Plan Yet",
|
||||
systemImage: "list.bullet.clipboard",
|
||||
description: Text(
|
||||
"When an agent presents a plan for review, it will remain available here."))
|
||||
}
|
||||
}
|
||||
.background(DetailSurface(layer: .inner))
|
||||
}
|
||||
|
||||
/// Pure selection helper kept internal for the pane header and focused tests.
|
||||
static func latestPlan(
|
||||
events: [AgentEvent], approvals: [ApprovalRequest]
|
||||
) -> PlanReview.Plan? {
|
||||
if let current = approvals.first(where: { PlanReview.isPlanTool($0.toolName) }),
|
||||
let plan = PlanReview.plan(from: current.input) {
|
||||
return plan
|
||||
}
|
||||
for event in events.reversed() {
|
||||
let call: ToolCall? = switch event.kind {
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call): call
|
||||
default: nil
|
||||
}
|
||||
if let call, PlanReview.isPlanTool(call.name),
|
||||
let plan = PlanReview.plan(from: call.input) {
|
||||
return plan
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -3035,7 +3035,7 @@ struct SessionDetailView: View {
|
||||
if let approval = store.openApprovals.first {
|
||||
switch approval.toolName {
|
||||
case AskUserQuestion.toolName: return "Waiting for answers…"
|
||||
case ExitPlanMode.toolName: return "Waiting for plan review…"
|
||||
case ExitPlanMode.toolName, PlanReview.toolName: return "Waiting for plan review…"
|
||||
default: return "Waiting for approval…"
|
||||
}
|
||||
}
|
||||
@@ -5002,6 +5002,7 @@ private struct AnimatedEllipsis: View {
|
||||
|
||||
struct ApprovalBar: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(PanelLayoutStore.self) private var panels
|
||||
@Environment(\.appPalette) private var palette
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
let request: ApprovalRequest
|
||||
@@ -5132,9 +5133,9 @@ struct ApprovalBar: View {
|
||||
/// The plan of an `ExitPlanMode` request, when this is one — the agent presenting the plan it
|
||||
/// wrote and asking to leave plan mode. Rendered as the formatted-Markdown plan card instead of
|
||||
/// the escaped-JSON blob the generic detail box would show. Nil for any other tool.
|
||||
private var planToApprove: ExitPlanMode.Plan? {
|
||||
guard request.toolName == ExitPlanMode.toolName else { return nil }
|
||||
return ExitPlanMode.plan(from: request.input)
|
||||
private var planToApprove: PlanReview.Plan? {
|
||||
guard PlanReview.isPlanTool(request.toolName) else { return nil }
|
||||
return PlanReview.plan(from: request.input)
|
||||
}
|
||||
|
||||
/// Whether a structured card carries the request's detail — when one does, the title row
|
||||
@@ -5147,7 +5148,18 @@ struct ApprovalBar: View {
|
||||
/// Kept separate from `planToApprove`: newer Claude builds may omit the Markdown from the
|
||||
/// permission payload. The review must still expose only Accept / Deny / Revise and must never
|
||||
/// fall back to generic Allow Always behavior just because there is no renderable plan body.
|
||||
private var isExitPlanMode: Bool { request.toolName == ExitPlanMode.toolName }
|
||||
private var isPlanReview: Bool { PlanReview.isPlanTool(request.toolName) }
|
||||
private var isLegacyExitPlanMode: Bool { request.toolName == ExitPlanMode.toolName }
|
||||
|
||||
private var planRejectionReason: String {
|
||||
isLegacyExitPlanMode ? ExitPlanMode.rejectionReason : PlanReview.rejectionReason
|
||||
}
|
||||
|
||||
private func planRevisionReason(_ feedback: String) -> String? {
|
||||
isLegacyExitPlanMode
|
||||
? ExitPlanMode.revisionReason(feedback: feedback)
|
||||
: PlanReview.revisionReason(feedback: feedback)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Title and actions are stacked vertically so the title (often a long file
|
||||
@@ -5229,6 +5241,17 @@ struct ApprovalBar: View {
|
||||
// An `ExitPlanMode` call: render the plan the agent presents as formatted Markdown
|
||||
// (the same card the transcript shows), instead of the raw `{"plan": …}` JSON.
|
||||
ExitPlanModeCard(plan: planToApprove, maxContentHeight: 400)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
panels.revealPlan()
|
||||
} label: {
|
||||
Label("View in side panel", systemImage: "sidebar.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Keep the rich plan visible beside the chat")
|
||||
}
|
||||
} else if !detail.isEmpty, !request.title.contains(detail) {
|
||||
// Full, untruncated content in a monospaced block — the command wraps and
|
||||
// the box fits its content, only scrolling vertically once the content is
|
||||
@@ -5237,9 +5260,9 @@ struct ApprovalBar: View {
|
||||
// title (a short command the title already shows in full needs no echo).
|
||||
ApprovalDetailBox(text: detail)
|
||||
}
|
||||
if isExitPlanMode, revisingPlan {
|
||||
if isPlanReview, revisingPlan {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("What should Claude change?")
|
||||
Text("What should the agent change?")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextField(
|
||||
"Describe the changes you want in the plan…",
|
||||
@@ -5250,7 +5273,7 @@ struct ApprovalBar: View {
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
if isExitPlanMode {
|
||||
if isPlanReview {
|
||||
if revisingPlan {
|
||||
Button("Cancel") {
|
||||
planRevision = ""
|
||||
@@ -5260,10 +5283,10 @@ struct ApprovalBar: View {
|
||||
Button("Send Revision") { submitPlanRevision() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(ExitPlanMode.revisionReason(feedback: planRevision) == nil)
|
||||
.disabled(planRevisionReason(planRevision) == nil)
|
||||
} else {
|
||||
Button("Deny") {
|
||||
respond(.deny(reason: ExitPlanMode.rejectionReason))
|
||||
respond(.deny(reason: planRejectionReason))
|
||||
}
|
||||
Spacer()
|
||||
Button("Revise") { revisingPlan = true }
|
||||
@@ -5346,7 +5369,7 @@ struct ApprovalBar: View {
|
||||
}
|
||||
|
||||
private func submitPlanRevision() {
|
||||
guard let reason = ExitPlanMode.revisionReason(feedback: planRevision) else { return }
|
||||
guard let reason = planRevisionReason(planRevision) else { return }
|
||||
respond(.deny(reason: reason))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,8 +529,8 @@ struct TranscriptCardPresentationBuilder {
|
||||
questionAnswer = .init(questions: questions, answers: answers)
|
||||
}
|
||||
}
|
||||
if kind == .generic, call.name == ExitPlanMode.toolName,
|
||||
let parsed = ExitPlanMode.plan(from: call.input) {
|
||||
if kind == .generic, PlanReview.isPlanTool(call.name),
|
||||
let parsed = PlanReview.plan(from: call.input) {
|
||||
kind = .plan
|
||||
plan = parsed
|
||||
markdown = [parsed.markdown]
|
||||
|
||||
@@ -99,8 +99,8 @@ struct TranscriptRenderBenchmarkStatistics: Equatable, Sendable {
|
||||
{
|
||||
return .answeredQuestion
|
||||
}
|
||||
if call.name == ExitPlanMode.toolName,
|
||||
ExitPlanMode.plan(from: call.input) != nil
|
||||
if PlanReview.isPlanTool(call.name),
|
||||
PlanReview.plan(from: call.input) != nil
|
||||
{
|
||||
return .plan
|
||||
}
|
||||
@@ -131,8 +131,8 @@ struct TranscriptRenderBenchmarkStatistics: Equatable, Sendable {
|
||||
}
|
||||
|
||||
func alwaysVisibleCardMarkdownBytes(_ call: ToolCall) -> Int {
|
||||
if call.name == ExitPlanMode.toolName,
|
||||
let plan = ExitPlanMode.plan(from: call.input)
|
||||
if PlanReview.isPlanTool(call.name),
|
||||
let plan = PlanReview.plan(from: call.input)
|
||||
{
|
||||
return plan.markdown.utf8.count
|
||||
}
|
||||
|
||||
@@ -476,8 +476,8 @@ struct TranscriptRow: View, Equatable {
|
||||
/// carrying no plan text. The plan is on the call input, so the card shows whether or not the
|
||||
/// user has approved it yet.
|
||||
private func planCard(call: ToolCall) -> ExitPlanModeCard? {
|
||||
guard call.name == ExitPlanMode.toolName,
|
||||
let plan = ExitPlanMode.plan(from: call.input)
|
||||
guard PlanReview.isPlanTool(call.name),
|
||||
let plan = PlanReview.plan(from: call.input)
|
||||
else { return nil }
|
||||
return ExitPlanModeCard(plan: plan)
|
||||
}
|
||||
@@ -609,7 +609,8 @@ struct TranscriptRow: View, Equatable {
|
||||
case "Task", "Agent", MCPApprovalServer.qualifiedOrchestraSubagentToolName: "person.2"
|
||||
case "WebFetch", "WebSearch": "globe"
|
||||
case "TodoWrite", "TaskCreate", "TaskUpdate": "checklist"
|
||||
case ExitPlanMode.toolName: "list.bullet.clipboard"
|
||||
case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName:
|
||||
"list.bullet.clipboard"
|
||||
// A host_exec run on the macOS host, outside the sandbox — the desktop glyph the
|
||||
// approval card and the in-chat `HostExecToolCard` use, so it reads alike everywhere.
|
||||
case MCPApprovalServer.qualifiedHostExecToolName: "desktopcomputer"
|
||||
|
||||
@@ -839,6 +839,43 @@ public enum AskUserResult: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome returned to an agent blocked in Nucleic's provider-neutral plan-review tool.
|
||||
/// `revise` is intentionally distinct from `denied`: revision tells the agent to update and
|
||||
/// resubmit the plan, while denial withholds authorization and ends this review cycle.
|
||||
public enum PlanReviewResult: Sendable, Equatable {
|
||||
case approved
|
||||
case revise(feedback: String)
|
||||
case denied(message: String)
|
||||
|
||||
public func wireJSON() -> String {
|
||||
switch self {
|
||||
case .approved:
|
||||
return JSONValue.object([
|
||||
"ok": .bool(true),
|
||||
"approved": .bool(true),
|
||||
"decision": .string("approved"),
|
||||
"message": .string("The user approved the plan. You may begin implementation."),
|
||||
]).canonicalString()
|
||||
case .revise(let feedback):
|
||||
return JSONValue.object([
|
||||
"ok": .bool(true),
|
||||
"approved": .bool(false),
|
||||
"decision": .string("revise"),
|
||||
"feedback": .string(feedback),
|
||||
"message": .string(
|
||||
"Revise the plan using the user's feedback and submit it for approval again. Do not implement yet."),
|
||||
]).canonicalString()
|
||||
case .denied(let message):
|
||||
return JSONValue.object([
|
||||
"ok": .bool(true),
|
||||
"approved": .bool(false),
|
||||
"decision": .string("denied"),
|
||||
"message": .string(message),
|
||||
]).canonicalString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a worker for the supervisor and returns immediately with a tracking id (non-blocking).
|
||||
public typealias OrchestraSubagentSpawner =
|
||||
@MainActor @Sendable (OrchestraSubagentRequest) async -> OrchestraSubagentResult
|
||||
@@ -905,7 +942,7 @@ enum NucleicApprovalPolicy {
|
||||
/// Tools whose purpose is to collect a deliberate user decision rather than merely request
|
||||
/// permission for an action. They must bypass both remembered allow rules and Auto mode.
|
||||
static func requiresExplicitUserDecision(toolName: String) -> Bool {
|
||||
toolName == AskUserQuestion.toolName || toolName == ExitPlanMode.toolName
|
||||
toolName == AskUserQuestion.toolName || PlanReview.isPlanTool(toolName)
|
||||
}
|
||||
|
||||
static func shouldAutoApprove(
|
||||
|
||||
@@ -1077,6 +1077,16 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
return await self.handleAskUserCall(call)
|
||||
}
|
||||
}
|
||||
// The provider-neutral ExitPlanMode counterpart. Unlike ordinary questions, plan approval
|
||||
// is available to every agent, including managed workers, so they all reach the user through
|
||||
// one explicit Accept / Revise / Deny interface.
|
||||
await server.registerPlanReview(token: token) { [weak self] call in
|
||||
guard let self else {
|
||||
return .denied(
|
||||
message: "The session ended before the plan could be reviewed; do not implement it.")
|
||||
}
|
||||
return await self.handlePlanReviewCall(call)
|
||||
}
|
||||
if run.orchestraActive, let replyToWorker = run.orchestraReplyToWorkerHandler {
|
||||
await server.registerOrchestraReplyToWorker(token: token) { call in
|
||||
let request = OrchestraReplyRequest(
|
||||
@@ -1607,12 +1617,14 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
if run.orchestraActive, run.orchestraPlanHandler != nil {
|
||||
allowedTools.append(MCPApprovalServer.qualifiedOrchestraPlanToolName)
|
||||
}
|
||||
// `nucleic_ask_user` — pre-allowed so the call reaches our handler, which raises the
|
||||
// question card itself. Gating it on the permission path would put an approval prompt in
|
||||
// front of an approval prompt. Present for every non-worker run (see registerNucleicTools).
|
||||
// Human-decision tools are pre-allowed so calls reach their handlers, which raise the
|
||||
// actual question/plan card. Gating them here would put an approval prompt in front of
|
||||
// an approval prompt. Ordinary questions remain supervisor-routed for workers; plan
|
||||
// review is intentionally available to every agent.
|
||||
if run.orchestraAskSupervisorHandler == nil {
|
||||
allowedTools.append(MCPApprovalServer.qualifiedAskUserToolName)
|
||||
}
|
||||
allowedTools.append(MCPApprovalServer.qualifiedPlanReviewToolName)
|
||||
// Supervisor drive/reply tools — pre-allowed so they reach our handlers directly, like the
|
||||
// spawner (they suspend/mutate the worker channel, never the permission path).
|
||||
if run.orchestraActive, run.orchestraSuperviseHandler != nil {
|
||||
@@ -2443,7 +2455,42 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
+ "Nucleic's lifecycle-aware cross-turn wake mechanism."
|
||||
}
|
||||
|
||||
// MARK: - Ask-the-user bridge (nucleic_ask_user)
|
||||
// MARK: - Human-decision bridges
|
||||
|
||||
/// Present an agent's Markdown plan through the shared approval coordinator and translate the
|
||||
/// ordinary Nucleic decision back into the plan tool's explicit three-way result.
|
||||
private func handlePlanReviewCall(
|
||||
_ call: MCPApprovalServer.PlanReviewCall
|
||||
) async -> PlanReviewResult {
|
||||
let request = ApprovalRequest(
|
||||
id: .generate(),
|
||||
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
|
||||
toolCallID: nil,
|
||||
toolName: PlanReview.toolName,
|
||||
input: call.input,
|
||||
title: "Review plan",
|
||||
risk: .readOnly,
|
||||
createdAt: configuration.now())
|
||||
|
||||
emit(.approvalRequested(request), nativeType: "mcp/tools/call")
|
||||
let resolved = await approvals.waitForDecision(request)
|
||||
emit(.approvalResolved(resolved), nativeType: nil)
|
||||
|
||||
switch resolved.decision {
|
||||
case .allow, .allowAlways:
|
||||
return .approved
|
||||
case .deny(let reason):
|
||||
if let feedback = PlanReview.revisionFeedback(from: reason) {
|
||||
return .revise(feedback: feedback)
|
||||
}
|
||||
return .denied(
|
||||
message: reason ?? "The user denied the plan. Do not begin implementation.")
|
||||
case .cancelRun:
|
||||
interruptRequested = true
|
||||
handle?.sendSignal(SIGINT)
|
||||
return .denied(message: "The user cancelled the run. Do not begin implementation.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the agent's multiple-choice questions in front of the human and block until they answer —
|
||||
/// Nucleic's provider-neutral `AskUserQuestion`.
|
||||
|
||||
@@ -436,6 +436,17 @@ public actor MCPApprovalServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `nucleic_review_plan` call. The original input is preserved so the approval and
|
||||
/// transcript surfaces render exactly what the agent submitted.
|
||||
public struct PlanReviewCall: Sendable {
|
||||
public let plan: PlanReview.Plan
|
||||
public let input: JSONValue
|
||||
public init(plan: PlanReview.Plan, input: JSONValue) {
|
||||
self.plan = plan
|
||||
self.input = input
|
||||
}
|
||||
}
|
||||
|
||||
/// A supervisor's `nucleic_reply_to_worker` call: the answer to a worker waiting in
|
||||
/// `nucleic_ask_supervisor`, addressed by the `worker_id` from the spawn ack.
|
||||
public struct OrchestraReplyToWorkerCall: Sendable {
|
||||
@@ -756,6 +767,8 @@ public actor MCPApprovalServer {
|
||||
public typealias OrchestraPlanServerHandler = @Sendable (OrchestraPlanCall) async -> OrchestraPlanResult
|
||||
/// `nucleic_ask_user` — surfaces the questions to the human and blocks for their answer.
|
||||
public typealias AskUserServerHandler = @Sendable (AskUserCall) async -> AskUserResult
|
||||
/// `nucleic_review_plan` — surfaces a Markdown plan and blocks for Accept / Revise / Deny.
|
||||
public typealias PlanReviewServerHandler = @Sendable (PlanReviewCall) async -> PlanReviewResult
|
||||
/// Supervisor-side: `nucleic_supervise` — blocks until worker events arrive (or the named batch
|
||||
/// finishes), then drains them. The argument is the optional batch to wait on.
|
||||
public typealias OrchestraSuperviseServerHandler = @Sendable (String?) async -> OrchestraSuperviseResult
|
||||
@@ -957,6 +970,9 @@ public actor MCPApprovalServer {
|
||||
/// orchestrator can clarify intent regardless of which model is driving.
|
||||
public static let askUserToolName = "nucleic_ask_user"
|
||||
public static let qualifiedAskUserToolName = "mcp__nucleic__nucleic_ask_user"
|
||||
/// Provider-neutral counterpart to Claude Code's built-in `ExitPlanMode`.
|
||||
public static let planReviewToolName = PlanReview.toolName
|
||||
public static let qualifiedPlanReviewToolName = PlanReview.qualifiedToolName
|
||||
/// Supervisor drive loop: block until worker events arrive, then drain them.
|
||||
public static let orchestraSuperviseToolName = "nucleic_supervise"
|
||||
public static let qualifiedOrchestraSuperviseToolName = "mcp__nucleic__nucleic_supervise"
|
||||
@@ -1011,6 +1027,7 @@ public actor MCPApprovalServer {
|
||||
private var orchestraSubagentHandlers: [String: OrchestraSubagentHandler] = [:]
|
||||
private var orchestraPlanHandlers: [String: OrchestraPlanServerHandler] = [:]
|
||||
private var askUserHandlers: [String: AskUserServerHandler] = [:]
|
||||
private var planReviewHandlers: [String: PlanReviewServerHandler] = [:]
|
||||
private var orchestraSuperviseHandlers: [String: OrchestraSuperviseServerHandler] = [:]
|
||||
private var orchestraReplyToWorkerHandlers: [String: OrchestraReplyToWorkerServerHandler] = [:]
|
||||
private var orchestraArchiveSubsessionHandlers:
|
||||
@@ -1135,6 +1152,7 @@ public actor MCPApprovalServer {
|
||||
boundTCPHost = nil
|
||||
boundUnixSocketPath = nil
|
||||
handlers.removeAll()
|
||||
planReviewHandlers.removeAll()
|
||||
preToolUseHandlers.removeAll()
|
||||
hostExecHandlers.removeAll()
|
||||
copyArtifactHandlers.removeAll()
|
||||
@@ -1252,6 +1270,12 @@ public actor MCPApprovalServer {
|
||||
askUserHandlers[token] = handler
|
||||
}
|
||||
|
||||
/// Register the cross-provider plan review tool. Like `nucleic_ask_user`, the handler owns
|
||||
/// the blocking human round-trip; registration only advertises and routes the call.
|
||||
public func registerPlanReview(token: String, handler: @escaping PlanReviewServerHandler) {
|
||||
planReviewHandlers[token] = handler
|
||||
}
|
||||
|
||||
/// Register the supervisor-side `nucleic_supervise` handler — advertises the tool and, on call,
|
||||
/// blocks until the supervisor's workers report events, then returns the drained batch.
|
||||
public func registerOrchestraSupervise(
|
||||
@@ -1414,6 +1438,7 @@ public actor MCPApprovalServer {
|
||||
orchestraSubagentHandlers.removeValue(forKey: token)
|
||||
orchestraPlanHandlers.removeValue(forKey: token)
|
||||
askUserHandlers.removeValue(forKey: token)
|
||||
planReviewHandlers.removeValue(forKey: token)
|
||||
orchestraSuperviseHandlers.removeValue(forKey: token)
|
||||
orchestraReplyToWorkerHandlers.removeValue(forKey: token)
|
||||
orchestraArchiveSubsessionHandlers.removeValue(forKey: token)
|
||||
@@ -1564,6 +1589,7 @@ public actor MCPApprovalServer {
|
||||
|| orchestraSubagentHandlers[token] != nil
|
||||
|| orchestraPlanHandlers[token] != nil
|
||||
|| askUserHandlers[token] != nil
|
||||
|| planReviewHandlers[token] != nil
|
||||
|| orchestraSuperviseHandlers[token] != nil
|
||||
|| orchestraReplyToWorkerHandlers[token] != nil
|
||||
|| orchestraArchiveSubsessionHandlers[token] != nil
|
||||
@@ -2529,6 +2555,35 @@ public actor MCPApprovalServer {
|
||||
]),
|
||||
]))
|
||||
}
|
||||
// Plan review — the same blocking three-way decision on every provider. It is
|
||||
// deliberately separate from Orchestra's `nucleic_delegate_plan`: this tool asks the
|
||||
// human to authorize implementation; the Orchestra tool dispatches approved work.
|
||||
if planReviewHandlers[token] != nil {
|
||||
tools.append(
|
||||
.object([
|
||||
"name": .string(Self.planReviewToolName),
|
||||
"description": .string(
|
||||
"Present a complete Markdown plan to the USER and BLOCK for their review "
|
||||
+ "before implementation. Use this when planning is complete and user "
|
||||
+ "approval is required. Pass the full plan in `plan`. Nucleic shows it "
|
||||
+ "in the composer and Plan side panel with Accept, Revise, and Deny. "
|
||||
+ "Returns decision=`approved`, `revise` (with feedback), or `denied`. "
|
||||
+ "Only begin implementation after `approved`; on `revise`, update the "
|
||||
+ "plan and call this tool again; on `denied`, do not implement it."),
|
||||
"inputSchema": .object([
|
||||
"type": .string("object"),
|
||||
"properties": .object([
|
||||
"plan": .object([
|
||||
"type": .string("string"),
|
||||
"description": .string(
|
||||
"The complete proposed plan in Markdown, including validation "
|
||||
+ "and important risks or tradeoffs."),
|
||||
]),
|
||||
]),
|
||||
"required": .array([.string("plan")]),
|
||||
]),
|
||||
]))
|
||||
}
|
||||
// Supervisor drive loop — advertised alongside nucleic_subagent. No readOnlyHint: this is
|
||||
// the single serializing wait the supervisor blocks on, not a concurrent sibling call.
|
||||
if orchestraSuperviseHandlers[token] != nil {
|
||||
@@ -3089,6 +3144,26 @@ public actor MCPApprovalServer {
|
||||
let reply = await askUserHandler(AskUserCall(questions: questions, input: input))
|
||||
return toolResult(id: id, text: reply.wireJSON())
|
||||
|
||||
case Self.planReviewToolName:
|
||||
let input = arguments ?? .object([:])
|
||||
guard let planReviewHandler = planReviewHandlers[token] else {
|
||||
return toolResult(
|
||||
id: id,
|
||||
text: PlanReviewResult.denied(
|
||||
message: "Plan review is not available in this session; do not treat the plan as approved.")
|
||||
.wireJSON())
|
||||
}
|
||||
guard let plan = PlanReview.plan(from: input) else {
|
||||
return toolResult(
|
||||
id: id,
|
||||
text: PlanReviewResult.denied(
|
||||
message: "No plan was submitted. Pass a non-empty Markdown `plan` and request review again.")
|
||||
.wireJSON())
|
||||
}
|
||||
// Suspends until the human accepts, requests a revision, or denies the plan.
|
||||
let reply = await planReviewHandler(PlanReviewCall(plan: plan, input: input))
|
||||
return toolResult(id: id, text: reply.wireJSON())
|
||||
|
||||
case Self.orchestraSuperviseToolName:
|
||||
let awaitedBatch = Self.trimmedNonEmpty(arguments?["batch"]?.stringValue)
|
||||
guard let superviseHandler = orchestraSuperviseHandlers[token] else {
|
||||
|
||||
@@ -74,6 +74,9 @@ public actor ACPBackend: AgentBackend {
|
||||
/// agent execs inside it (stdio over vsock) instead of on the host — the same per-family control
|
||||
/// container isolation Claude gets. `nil` → always host.
|
||||
private let containerManager: ContainerManager?
|
||||
/// Dedicated loopback server for host-run ACP agents. Containerized agents instead share the
|
||||
/// token-multiplexed server vended by `approvalServerRegistry`.
|
||||
private let hostApprovalServer: MCPApprovalServer
|
||||
/// Provider-neutral Nucleic MCP handlers shared across every agent backend.
|
||||
private let nucleicToolRuntime: ClaudeCodeBackend
|
||||
/// Name of the container the current run execs in (for the teardown `finished` callback).
|
||||
@@ -90,6 +93,8 @@ public actor ACPBackend: AgentBackend {
|
||||
/// The interceptor server + token for the current containerized run (for teardown unregister).
|
||||
private var interceptorServer: MCPApprovalServer?
|
||||
private var interceptorToken: String?
|
||||
private var nucleicMCPURL: String?
|
||||
private var ownsInterceptorServer = false
|
||||
|
||||
private var handle: (any ProcessHandle)?
|
||||
private var rpc: JSONRPCConnection?
|
||||
@@ -123,6 +128,7 @@ public actor ACPBackend: AgentBackend {
|
||||
conflictCoordinator: ConflictCoordinator? = nil,
|
||||
containerManager: ContainerManager? = nil,
|
||||
macVMManager: MacVMManager? = nil,
|
||||
approvalServer: MCPApprovalServer = MCPApprovalServer(),
|
||||
approvalServerRegistry: ApprovalServerRegistry? = nil
|
||||
) {
|
||||
self.configuration = configuration
|
||||
@@ -131,6 +137,7 @@ public actor ACPBackend: AgentBackend {
|
||||
self.conflictCoordinator = conflictCoordinator
|
||||
self.approvalServerRegistry = approvalServerRegistry
|
||||
self.containerManager = containerManager
|
||||
self.hostApprovalServer = approvalServer
|
||||
self.nucleicToolRuntime = ClaudeCodeBackend(
|
||||
processHost: processHost, approvals: approvals,
|
||||
containerManager: containerManager, macVMManager: macVMManager,
|
||||
@@ -302,12 +309,13 @@ public actor ACPBackend: AgentBackend {
|
||||
// Git/gh/command interceptor: Grok's in-container shims POST observed ops to the
|
||||
// shared per-container approval server (over the relayed socket, via the in-guest
|
||||
// bridge), which forwards them to the conflict coordinator (locks + autoship) — the
|
||||
// same observation Claude gets. Grok's approvals stay native ACP, so only the report
|
||||
// routes are registered, not the MCP approve tool.
|
||||
// same observation Claude gets. File/command approvals stay native ACP, while
|
||||
// Nucleic-owned capability and human-decision tools use the MCP route below.
|
||||
if let server = controlServer {
|
||||
let token = UUID().uuidString
|
||||
interceptorServer = server
|
||||
interceptorToken = token
|
||||
nucleicMCPURL = "http://127.0.0.1:\(ContainerSpec.controlBridgePort)/mcp"
|
||||
await server.registerGitReport(token: token) { [weak self] call in
|
||||
Task { await self?.handleGitReport(call) }
|
||||
}
|
||||
@@ -338,6 +346,18 @@ public actor ACPBackend: AgentBackend {
|
||||
argv: [configuration.executable] + configuration.agent.launchArgs,
|
||||
uid: cspec.runAsUID, gid: cspec.runAsGID)
|
||||
} else {
|
||||
let token = UUID().uuidString
|
||||
let port = try await hostApprovalServer.start(host: "127.0.0.1")
|
||||
interceptorServer = hostApprovalServer
|
||||
interceptorToken = token
|
||||
nucleicMCPURL = "http://127.0.0.1:\(port)/mcp"
|
||||
ownsInterceptorServer = true
|
||||
await nucleicToolRuntime.configurePlatformToolRuntime(for: run) {
|
||||
[weak self] kind, nativeType in
|
||||
Task { await self?.emit(kind, nativeType: nativeType) }
|
||||
}
|
||||
await nucleicToolRuntime.registerNucleicTools(
|
||||
on: hostApprovalServer, token: token, run: run)
|
||||
let spec = ProcessSpec(
|
||||
executable: configuration.executable,
|
||||
args: configuration.agent.launchArgs,
|
||||
@@ -517,8 +537,7 @@ public actor ACPBackend: AgentBackend {
|
||||
/// Advertise Nucleic MCP only inside a Control container. The in-guest HTTP endpoint is the
|
||||
/// control bridge, which relays over the session's vsock/Unix-socket control plane.
|
||||
private func nucleicMCPServers() -> JSONValue {
|
||||
guard let token = interceptorToken else { return .array([]) }
|
||||
let url = "http://127.0.0.1:\(ContainerSpec.controlBridgePort)/mcp"
|
||||
guard let token = interceptorToken, let url = nucleicMCPURL else { return .array([]) }
|
||||
return .array([
|
||||
.object([
|
||||
"type": .string("http"),
|
||||
@@ -599,8 +618,11 @@ public actor ACPBackend: AgentBackend {
|
||||
// other sessions in the box.
|
||||
await interceptorServer?.unregister(token: token)
|
||||
interceptorToken = nil
|
||||
interceptorServer = nil
|
||||
}
|
||||
if ownsInterceptorServer { await interceptorServer?.stop() }
|
||||
ownsInterceptorServer = false
|
||||
interceptorServer = nil
|
||||
nucleicMCPURL = nil
|
||||
if let name = activeContainerName {
|
||||
await containerManager?.finished(name: name)
|
||||
activeContainerName = nil
|
||||
|
||||
@@ -392,12 +392,21 @@ public actor GrokBuildBackend: AgentBackend {
|
||||
}
|
||||
}
|
||||
|
||||
if let cached = await approvals.cachedDecision(toolName: toolName, input: input) {
|
||||
let requiresExplicitUserDecision = NucleicApprovalPolicy.requiresExplicitUserDecision(
|
||||
toolName: toolName)
|
||||
if !requiresExplicitUserDecision,
|
||||
let cached = await approvals.cachedDecision(toolName: toolName, input: input)
|
||||
{
|
||||
return Self.hookDecision(cached.decision)
|
||||
}
|
||||
let risk = RiskClassifier.classify(toolName: toolName, input: input)
|
||||
if risk == .readOnly { return .allow }
|
||||
if NucleicApprovalPolicy.shouldAutoApprove(autoApprove: autoApprove, risk: risk) {
|
||||
if risk == .readOnly, !requiresExplicitUserDecision {
|
||||
return .allow
|
||||
}
|
||||
if NucleicApprovalPolicy.shouldAutoApprove(
|
||||
autoApprove: autoApprove, risk: risk,
|
||||
requiresExplicitUserDecision: requiresExplicitUserDecision)
|
||||
{
|
||||
return .allow
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ public enum RiskClassifier {
|
||||
return classifyCommand(input["command"]?.stringValue ?? "")
|
||||
case "Task", "Agent", "KillShell":
|
||||
return .execute
|
||||
case ExitPlanMode.toolName:
|
||||
case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName:
|
||||
// Presenting a plan changes nothing on disk — it's the agent asking to leave plan
|
||||
// mode and start coding. Read-only keeps the approval from reading as a risky action.
|
||||
return .readOnly
|
||||
@@ -91,8 +91,8 @@ public enum RiskClassifier {
|
||||
if let query = input["query"]?.stringValue {
|
||||
return "Search: \(truncated(query))"
|
||||
}
|
||||
case ExitPlanMode.toolName:
|
||||
return "Ready to code?"
|
||||
case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName:
|
||||
return "Review plan"
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -116,10 +116,10 @@ public enum RiskClassifier {
|
||||
if let url = input["url"]?.stringValue { return url }
|
||||
case "WebSearch":
|
||||
if let query = input["query"]?.stringValue { return query }
|
||||
case ExitPlanMode.toolName:
|
||||
case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName:
|
||||
// The plan body itself (Markdown source) rather than the escaped-JSON input, so the
|
||||
// approval reads the plan the same way the transcript's `ExitPlanModeCard` renders it.
|
||||
if let plan = ExitPlanMode.plan(from: input) { return plan.markdown }
|
||||
if let plan = PlanReview.plan(from: input) { return plan.markdown }
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ public enum SandboxToolDisplay {
|
||||
case MCPApprovalServer.orchestraArchiveSubsessionToolName: "Archive subagent"
|
||||
case MCPApprovalServer.orchestraAskSupervisorToolName: "Question for supervisor"
|
||||
case MCPApprovalServer.askUserToolName: "Question for you"
|
||||
case MCPApprovalServer.planReviewToolName: "Plan review"
|
||||
case MCPApprovalServer.meshSendMessageToolName: "Mesh message"
|
||||
case MCPApprovalServer.meshSubscribeTopicToolName: "Mesh topic"
|
||||
case MCPApprovalServer.meshWaitForMessageToolName: "Waiting for a mesh message"
|
||||
@@ -117,6 +118,7 @@ public enum SandboxToolDisplay {
|
||||
// Both of these park the session on a human, which is the one thing a "working" row that
|
||||
// said "Running…" would hide: nothing is going to move until someone answers.
|
||||
case MCPApprovalServer.askUserToolName: "Waiting on your answer…"
|
||||
case MCPApprovalServer.planReviewToolName: "Waiting for plan review…"
|
||||
case MCPApprovalServer.meshWaitForMessageToolName: "Waiting for a mesh message…"
|
||||
case MCPApprovalServer.meshSendMessageToolName: "Sending a mesh message…"
|
||||
case MCPApprovalServer.meshSubscribeTopicToolName: "Subscribing to a topic…"
|
||||
@@ -152,6 +154,7 @@ public enum SandboxToolDisplay {
|
||||
case MCPApprovalServer.orchestraArchiveSubsessionToolName: ("archivesubsession", "Archived a subagent")
|
||||
case MCPApprovalServer.orchestraAskSupervisorToolName: ("asksupervisor", "Asked the supervisor")
|
||||
case MCPApprovalServer.askUserToolName: ("askuser", "Asked you")
|
||||
case MCPApprovalServer.planReviewToolName: ("planreview", "Presented a plan")
|
||||
case MCPApprovalServer.meshSendMessageToolName: ("meshsend", "Sent a mesh message")
|
||||
case MCPApprovalServer.meshSubscribeTopicToolName: ("meshsubscribe", "Subscribed to a topic")
|
||||
case MCPApprovalServer.meshWaitForMessageToolName: ("meshwait", "Waited for a mesh message")
|
||||
@@ -184,6 +187,7 @@ public enum SandboxToolDisplay {
|
||||
case MCPApprovalServer.orchestraAskSupervisorToolName: "questionmark.bubble"
|
||||
// Distinct from the supervisor's bubble: this one is pointed at a person.
|
||||
case MCPApprovalServer.askUserToolName: "person.crop.circle.badge.questionmark"
|
||||
case MCPApprovalServer.planReviewToolName: "list.bullet.clipboard"
|
||||
case MCPApprovalServer.meshSendMessageToolName: "paperplane"
|
||||
case MCPApprovalServer.meshSubscribeTopicToolName: "dot.radiowaves.left.and.right"
|
||||
case MCPApprovalServer.meshWaitForMessageToolName: "tray.and.arrow.down"
|
||||
@@ -270,6 +274,9 @@ public enum SandboxToolDisplay {
|
||||
case MCPApprovalServer.askUserToolName:
|
||||
return questionSummary(input)
|
||||
|
||||
case MCPApprovalServer.planReviewToolName:
|
||||
return arg("plan").map(singleLine)
|
||||
|
||||
case MCPApprovalServer.meshSendMessageToolName:
|
||||
return messageSummary(input)
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
/// Nucleic's provider-neutral plan-review contract.
|
||||
///
|
||||
/// Every agent receives the `nucleic_review_plan` MCP tool. The agent supplies a
|
||||
/// Markdown plan and the call blocks while Nucleic presents the same Accept / Revise / Deny
|
||||
/// interface on every client. Claude's native `ExitPlanMode` remains supported as a legacy wire
|
||||
/// shape; UI code can use ``isPlanTool(_:)`` and ``plan(from:)`` for either source.
|
||||
public enum PlanReview {
|
||||
public static let toolName = "nucleic_review_plan"
|
||||
public static let qualifiedToolName = "mcp__nucleic__nucleic_review_plan"
|
||||
|
||||
public typealias Plan = ExitPlanMode.Plan
|
||||
|
||||
/// Whether a tool name denotes either Nucleic's cross-provider review tool or Claude's
|
||||
/// built-in compatibility path.
|
||||
public static func isPlanTool(_ toolName: String) -> Bool {
|
||||
toolName == Self.toolName || toolName == Self.qualifiedToolName
|
||||
|| toolName == ExitPlanMode.toolName
|
||||
}
|
||||
|
||||
public static func plan(from input: JSONValue) -> Plan? {
|
||||
ExitPlanMode.plan(from: input)
|
||||
}
|
||||
|
||||
/// The denial returned by the Plan popup when the user does not authorize implementation.
|
||||
public static let rejectionReason =
|
||||
"The user denied this plan. Do not implement it. Ask what they would like to do next."
|
||||
|
||||
/// A stable prefix lets the MCP bridge distinguish a revision request from a final denial
|
||||
/// while still carrying useful prose through Nucleic's ordinary `Decision.deny(reason:)` wire.
|
||||
private static let revisionPrefix = "The user requested changes to the plan:"
|
||||
|
||||
public static func revisionReason(feedback: String) -> String? {
|
||||
let feedback = feedback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !feedback.isEmpty else { return nil }
|
||||
return """
|
||||
\(revisionPrefix)
|
||||
|
||||
\(feedback)
|
||||
|
||||
Revise the plan to address this feedback and submit it for approval again before implementing.
|
||||
"""
|
||||
}
|
||||
|
||||
/// Recover the user's requested changes from the internal denial reason emitted above.
|
||||
public static func revisionFeedback(from reason: String?) -> String? {
|
||||
guard let reason, reason.hasPrefix(revisionPrefix) else { return nil }
|
||||
let body = reason.dropFirst(revisionPrefix.count)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let suffix =
|
||||
"Revise the plan to address this feedback and submit it for approval again before implementing."
|
||||
let feedback = body.hasSuffix(suffix)
|
||||
? body.dropLast(suffix.count).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: body
|
||||
return feedback.isEmpty ? nil : String(feedback)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,36 @@ import Testing
|
||||
#expect(resolvedBy == "phone")
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(1))) func nativePlanExitRequiresExplicitReview() async throws {
|
||||
let executable = productsDirectory.appendingPathComponent("fake-grok").path
|
||||
let sandbox = try makeSandbox(fixture: """
|
||||
{"__fake__":"approval","tool_name":"ExitPlanMode","input":{"plan":"# Plan\\n\\n- Build\\n- Test"},"tool_call_id":"plan_1","expect":"deny"}
|
||||
{"type":"end","stopReason":"end_turn","sessionId":"grok-session-plan"}
|
||||
""")
|
||||
defer { try? FileManager.default.removeItem(at: sandbox.root) }
|
||||
|
||||
let backend = GrokBuildBackend(configuration: .init(executable: executable))
|
||||
let run = RunSpec(
|
||||
sessionID: SessionID(rawValue: "grok-plan-review"),
|
||||
worktree: sandbox.worktree.path, prompt: AgentInput(text: "Propose a plan"),
|
||||
model: "grok-build", autoApprove: true,
|
||||
extraEnv: [
|
||||
"FAKE_GROK_FIXTURE": sandbox.fixture.path,
|
||||
"FAKE_GROK_HEADLESS": "1",
|
||||
])
|
||||
|
||||
var sawReview = false
|
||||
for try await event in backend.start(run) {
|
||||
guard case .approvalRequested(let request) = event.kind else { continue }
|
||||
sawReview = true
|
||||
#expect(PlanReview.isPlanTool(request.toolName))
|
||||
#expect(request.risk == .readOnly)
|
||||
let reason = try #require(PlanReview.revisionReason(feedback: "Add rollback steps."))
|
||||
try await backend.respond(to: request.id, .deny(reason: reason), by: "test")
|
||||
}
|
||||
#expect(sawReview)
|
||||
}
|
||||
|
||||
@Test func resumePreservesCapabilitiesAndEffortTranslation() {
|
||||
let resume = ResumeSpec(
|
||||
sessionID: SessionID(rawValue: "grok-resume"), backendSessionID: "session-1",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import NucleicCore
|
||||
|
||||
@Suite struct PlanReviewTests {
|
||||
@Test func recognizesLegacyAndProviderNeutralToolNames() {
|
||||
#expect(PlanReview.isPlanTool(ExitPlanMode.toolName))
|
||||
#expect(PlanReview.isPlanTool(PlanReview.toolName))
|
||||
#expect(PlanReview.isPlanTool(PlanReview.qualifiedToolName))
|
||||
#expect(!PlanReview.isPlanTool("nucleic_delegate_plan"))
|
||||
}
|
||||
|
||||
@Test func revisionFeedbackRoundTrips() throws {
|
||||
let reason = try #require(PlanReview.revisionReason(feedback: " Add rollback steps. \n"))
|
||||
#expect(PlanReview.revisionFeedback(from: reason) == "Add rollback steps.")
|
||||
#expect(PlanReview.revisionFeedback(from: PlanReview.rejectionReason) == nil)
|
||||
#expect(PlanReview.revisionReason(feedback: " \n") == nil)
|
||||
}
|
||||
|
||||
@Test func resultWireDistinguishesAllThreeDecisions() {
|
||||
#expect(PlanReviewResult.approved.wireJSON().contains(#""decision":"approved""#))
|
||||
let revise = PlanReviewResult.revise(feedback: "Show tests").wireJSON()
|
||||
#expect(revise.contains(#""decision":"revise""#))
|
||||
#expect(revise.contains(#""feedback":"Show tests""#))
|
||||
#expect(PlanReviewResult.denied(message: "No").wireJSON()
|
||||
.contains(#""decision":"denied""#))
|
||||
}
|
||||
|
||||
@Test func managedWorkersAlsoReceivePlanReviewTool() async throws {
|
||||
let server = MCPApprovalServer()
|
||||
let port = try await server.start()
|
||||
defer { Task { await server.stop() } }
|
||||
|
||||
let runtime = ClaudeCodeBackend()
|
||||
let run = RunSpec(
|
||||
sessionID: SessionID(rawValue: "worker-plans"), worktree: "/repo",
|
||||
prompt: AgentInput(parts: []),
|
||||
orchestraAskSupervisorHandler: { _ in .answered("continue") })
|
||||
await runtime.registerNucleicTools(on: server, token: "worker-token", run: run)
|
||||
|
||||
let listed = try await post(
|
||||
["jsonrpc": "2.0", "id": 1, "method": "tools/list"],
|
||||
port: port, token: "worker-token")
|
||||
let names = listed["result"]?["tools"]?.arrayValue?.compactMap {
|
||||
$0["name"]?.stringValue
|
||||
} ?? []
|
||||
#expect(names.contains(PlanReview.toolName))
|
||||
#expect(!names.contains(MCPApprovalServer.askUserToolName))
|
||||
}
|
||||
|
||||
/// Drives the provider-neutral runtime the way Codex/Grok do: the MCP call becomes a
|
||||
/// normalized plan approval, revision feedback resolves it, and the explicit result returns
|
||||
/// to the agent.
|
||||
@Test func planReviewRoundTripsThroughSharedApprovals() async throws {
|
||||
let server = MCPApprovalServer()
|
||||
let port = try await server.start()
|
||||
defer { Task { await server.stop() } }
|
||||
|
||||
let approvals = ApprovalCoordinator()
|
||||
let runtime = ClaudeCodeBackend(approvals: approvals)
|
||||
let requests = RequestLog()
|
||||
let run = RunSpec(
|
||||
sessionID: SessionID(rawValue: "codex-plans"), worktree: "/repo",
|
||||
prompt: AgentInput(parts: []), autoApprove: true)
|
||||
await runtime.configurePlatformToolRuntime(for: run) { kind, _ in
|
||||
if case .approvalRequested(let request) = kind {
|
||||
Task { await requests.record(request) }
|
||||
}
|
||||
}
|
||||
await runtime.registerNucleicTools(on: server, token: "plan-token", run: run)
|
||||
|
||||
let listed = try await post(
|
||||
["jsonrpc": "2.0", "id": 1, "method": "tools/list"],
|
||||
port: port, token: "plan-token")
|
||||
let names = listed["result"]?["tools"]?.arrayValue?.compactMap {
|
||||
$0["name"]?.stringValue
|
||||
} ?? []
|
||||
#expect(names.contains(PlanReview.toolName))
|
||||
|
||||
let call = Task {
|
||||
try await self.post(
|
||||
[
|
||||
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||||
"params": [
|
||||
"name": .string(PlanReview.toolName),
|
||||
"arguments": ["plan": "# Ship\n\n- Build\n- Test"],
|
||||
],
|
||||
], port: port, token: "plan-token")
|
||||
}
|
||||
|
||||
let request = try #require(await requests.first(within: .seconds(5)))
|
||||
#expect(request.toolName == PlanReview.toolName)
|
||||
#expect(request.risk == .readOnly)
|
||||
#expect(PlanReview.plan(from: request.input)?.markdown.contains("- Test") == true)
|
||||
|
||||
let reason = try #require(PlanReview.revisionReason(feedback: "Add rollback."))
|
||||
_ = await approvals.resolve(request.id, .deny(reason: reason), by: "test-device")
|
||||
|
||||
let response = try await call.value
|
||||
let text = response["result"]?["content"]?[0]?["text"]?.stringValue
|
||||
#expect(text?.contains(#""decision":"revise""#) == true)
|
||||
#expect(text?.contains(#""feedback":"Add rollback.""#) == true)
|
||||
}
|
||||
|
||||
private func post(
|
||||
_ body: JSONValue, port: UInt16, token: String
|
||||
) async throws -> JSONValue {
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/mcp")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.httpBody = try body.encodedData()
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return try JSONValue(parsing: data)
|
||||
}
|
||||
|
||||
private actor RequestLog {
|
||||
private var requests: [ApprovalRequest] = []
|
||||
func record(_ request: ApprovalRequest) { requests.append(request) }
|
||||
func first(within timeout: Duration) async -> ApprovalRequest? {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
while requests.isEmpty, clock.now < deadline {
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
return requests.first
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,6 +348,7 @@ struct SandboxToolDisplayTests {
|
||||
MCPApprovalServer.qualifiedOrchestraReplyToWorkerToolName,
|
||||
MCPApprovalServer.qualifiedOrchestraAskSupervisorToolName,
|
||||
MCPApprovalServer.qualifiedAskUserToolName,
|
||||
MCPApprovalServer.qualifiedPlanReviewToolName,
|
||||
MCPApprovalServer.qualifiedMeshSendMessageToolName,
|
||||
MCPApprovalServer.qualifiedMeshSubscribeTopicToolName,
|
||||
MCPApprovalServer.qualifiedMeshWaitForMessageToolName,
|
||||
@@ -373,6 +374,7 @@ struct SandboxToolDisplayTests {
|
||||
"mac_vm_request_operator", "linux_vm_exec", "linux_vm_control", "linux_vm_computer",
|
||||
"linux_container", "nucleic_delegate_plan", "nucleic_supervise",
|
||||
"nucleic_reply_to_worker", "nucleic_ask_supervisor", "nucleic_ask_user",
|
||||
"nucleic_review_plan",
|
||||
"nucleic_send_message", "nucleic_subscribe_topic", "nucleic_wait_for_message",
|
||||
"nucleic_monitor",
|
||||
"copy_artifact",
|
||||
|
||||
@@ -170,13 +170,22 @@ struct ApprovalCardView: View {
|
||||
struct ExitPlanModeApprovalCardView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let approval: ApprovalRequest
|
||||
let plan: ExitPlanMode.Plan?
|
||||
let plan: PlanReview.Plan?
|
||||
var availableHeight: CGFloat = 0
|
||||
|
||||
@State private var revising = false
|
||||
@State private var feedback = ""
|
||||
|
||||
private var canRespond: Bool { store.connectivity.isLive }
|
||||
private var isLegacyExitPlanMode: Bool { approval.toolName == ExitPlanMode.toolName }
|
||||
private var rejectionReason: String {
|
||||
isLegacyExitPlanMode ? ExitPlanMode.rejectionReason : PlanReview.rejectionReason
|
||||
}
|
||||
private func revisionReason(_ feedback: String) -> String? {
|
||||
isLegacyExitPlanMode
|
||||
? ExitPlanMode.revisionReason(feedback: feedback)
|
||||
: PlanReview.revisionReason(feedback: feedback)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
@@ -193,13 +202,13 @@ struct ExitPlanModeApprovalCardView: View {
|
||||
// Claude versions that don't include the plan Markdown in the permission payload
|
||||
// still need the three-way review. State that plainly instead of degrading to the
|
||||
// generic permission card (which would incorrectly offer Allow Always).
|
||||
Text("Claude is ready to leave plan mode and begin implementation.")
|
||||
Text("The agent is ready to begin implementation and is waiting for plan review.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if revising {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("What should Claude change?")
|
||||
Text("What should the agent change?")
|
||||
.font(.footnote.weight(.semibold))
|
||||
TextField(
|
||||
"Describe the changes you want in the plan…",
|
||||
@@ -219,13 +228,13 @@ struct ExitPlanModeApprovalCardView: View {
|
||||
Button("Send Revision") { submitRevision() }
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canRespond || ExitPlanMode.revisionReason(feedback: feedback) == nil)
|
||||
.disabled(!canRespond || revisionReason(feedback) == nil)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
Button(role: .destructive) {
|
||||
store.respond(approval, .deny(reason: ExitPlanMode.rejectionReason))
|
||||
store.respond(approval, .deny(reason: rejectionReason))
|
||||
} label: {
|
||||
Text("Deny").frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -263,7 +272,7 @@ struct ExitPlanModeApprovalCardView: View {
|
||||
}
|
||||
|
||||
private func submitRevision() {
|
||||
guard let reason = ExitPlanMode.revisionReason(feedback: feedback) else { return }
|
||||
guard let reason = revisionReason(feedback) else { return }
|
||||
store.respond(approval, .deny(reason: reason))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,10 +638,10 @@ struct SessionDetailView: View {
|
||||
{
|
||||
AskUserQuestionCardView(
|
||||
approval: approval, questions: questions, availableHeight: availableHeight)
|
||||
} else if approval.toolName == ExitPlanMode.toolName {
|
||||
} else if PlanReview.isPlanTool(approval.toolName) {
|
||||
ExitPlanModeApprovalCardView(
|
||||
approval: approval,
|
||||
plan: ExitPlanMode.plan(from: approval.input),
|
||||
plan: PlanReview.plan(from: approval.input),
|
||||
availableHeight: availableHeight)
|
||||
} else {
|
||||
ApprovalCardView(approval: approval, availableHeight: availableHeight)
|
||||
@@ -860,7 +860,7 @@ struct SessionDetailView: View {
|
||||
if let approval = store.openApprovals.first {
|
||||
switch approval.toolName {
|
||||
case AskUserQuestion.toolName: return "Waiting for answers…"
|
||||
case ExitPlanMode.toolName: return "Waiting for plan review…"
|
||||
case ExitPlanMode.toolName, PlanReview.toolName: return "Waiting for plan review…"
|
||||
default: return "Waiting for approval…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,7 +582,8 @@ enum ToolGlyph {
|
||||
case "Task", "Agent", ToolGroup.orchestraSubagentToolName: return "person.2"
|
||||
case "TodoWrite": return "checklist"
|
||||
case "AskUserQuestion": return "questionmark.bubble"
|
||||
case ExitPlanMode.toolName: return "list.bullet.clipboard"
|
||||
case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName:
|
||||
return "list.bullet.clipboard"
|
||||
case HostCommandSummary.hostExecToolName: return "desktopcomputer"
|
||||
// The VM/container tools each get their own glyph — a screen for computer-use, a box for
|
||||
// a container — so they don't all collapse into the generic wrench.
|
||||
|
||||
@@ -121,7 +121,7 @@ struct ToolGroup: Equatable {
|
||||
name == "Task" || name == "Agent" || name == Self.orchestraSubagentToolName
|
||||
}
|
||||
var isAskUserQuestion: Bool { name == "AskUserQuestion" }
|
||||
var isExitPlanMode: Bool { name == ExitPlanMode.toolName }
|
||||
var isExitPlanMode: Bool { PlanReview.isPlanTool(name) }
|
||||
|
||||
/// The literal shell command a `Bash` call ran, if any (nil for every other tool). Used to
|
||||
/// detect a git-commit pipeline that should read as a structured commit card at the row level,
|
||||
|
||||
@@ -92,7 +92,7 @@ struct TranscriptRow: View {
|
||||
/// Nil for any other tool (which keeps the normal collapsible card) and for a call with no plan
|
||||
/// text. The plan is on the call input, so it shows whether or not the user has approved it yet.
|
||||
private func planCard(_ group: ToolGroup) -> ExitPlanModeCard? {
|
||||
guard group.isExitPlanMode, let plan = ExitPlanMode.plan(from: group.input) else { return nil }
|
||||
guard group.isExitPlanMode, let plan = PlanReview.plan(from: group.input) else { return nil }
|
||||
return ExitPlanModeCard(plan: plan)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user