From b3ae93359a20dc1146ac62ec5ec45d1b0c852a23 Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 2 Sep 2026 19:42:31 -0700 Subject: [PATCH 1/4] Let Copilot CLI host composite loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's supportsSubAgents row was written against a CLI with no fan-out. 1.0.80 lists /fleet (parallel subagent execution), /tasks (subagents and shell commands) and /subagents, plus --agent on the launch line — the capability a composite leans on — so the row flips to true and the picker stops refusing the pairing. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Pxwzz1AZnVE3peZ8qMejLa --- GraphcodeKit/Sources/Domain/BackendCapabilities.swift | 9 ++++++++- graphcode/Tests/CopilotBackendTests.swift | 11 +++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/GraphcodeKit/Sources/Domain/BackendCapabilities.swift b/GraphcodeKit/Sources/Domain/BackendCapabilities.swift index fdb158c6..f97b8dbd 100644 --- a/GraphcodeKit/Sources/Domain/BackendCapabilities.swift +++ b/GraphcodeKit/Sources/Domain/BackendCapabilities.swift @@ -102,11 +102,18 @@ extension CLISessionBackendKind { // timer of its own. Copilot has since grown one. Worth knowing if a recurring loop // runs once and stops: that is the symptom of a Copilot too old to have it, and // `copilot help commands` on the machine running the loop is where to check. + // + // `supportsSubAgents` was false for the same reason and flipped the same way: read + // off 1.0.80's `copilot help commands`, which lists `/fleet` ("enable fleet mode for + // parallel subagent execution"), `/tasks` ("view and manage tasks (subagents and + // shell commands)") and `/subagents`, plus `--agent ` on the launch line. + // That is the fan-out a composite leans on. The same age caveat applies: a + // composite whose Copilot workers never fan out is a Copilot older than that. return BackendCapabilities( supportsGoalMode: true, supportsHooks: false, supportsStructuredOutput: true, - supportsSubAgents: false, + supportsSubAgents: true, supportsMCP: true, supportsMidSessionInput: true, supportsInSessionRecurrence: true) diff --git a/graphcode/Tests/CopilotBackendTests.swift b/graphcode/Tests/CopilotBackendTests.swift index 332a6684..501dbd6c 100644 --- a/graphcode/Tests/CopilotBackendTests.swift +++ b/graphcode/Tests/CopilotBackendTests.swift @@ -83,7 +83,7 @@ struct CopilotBackendTests { } @Test - func copilotHostsEverythingButComposites() { + func copilotHostsEveryLoopType() { // Goal-based works because a goal is just a prompt plus a predicate the *daemon* // polls from outside — nothing about it needs a skill the agent has to own. #expect(CLISessionBackendKind.copilotCLI.canHost(.turnBased)) @@ -92,9 +92,12 @@ struct CopilotBackendTests { // Copilot had no `/loop` equivalent when this row was first written against 1.0.75; // it has one now, which is what allows this pairing (issue #3). #expect(CLISessionBackendKind.copilotCLI.canHost(.timeBased)) - // A composite is a graph of loops running inside one node, and sub-agent fan-out is - // still unverified here — the one row that stays refused. - #expect(!CLISessionBackendKind.copilotCLI.canHost(.composite)) + // A composite is a graph of loops running inside one node and leans on sub-agent + // fan-out, which was unverified when this row was first written. 1.0.80 lists + // `/fleet`, `/tasks` and `/subagents`, so the last refused pairing is allowed too. + #expect(CLISessionBackendKind.copilotCLI.canHost(.composite)) + #expect(CLISessionBackendKind.hosting(.composite).contains(.copilotCLI)) + #expect(CLISessionBackendKind.copilotCLI.canHost(.sketch)) } @Test From 144d4382b87f51652fb15251b73bf014f69f340d Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 2 Sep 2026 19:44:43 -0700 Subject: [PATCH 2/4] Workers added inside a composite run on the composite's backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composite never runs a session of its own; its workers do, and a worker created inside one with no backend named fell to NodeDraft.effectiveBackend — Claude Code. A composite labelled Copilot would have run Claude workers. Sub-graph creation now fills an unnamed backend from the composite (a findable creator still wins, a named backend wins over both), and the app's form seeds its picker with the open composite's backend the same way. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Pxwzz1AZnVE3peZ8qMejLa --- GraphcodeKit/Sources/GraphStore.swift | 9 ++++ .../Features/Project/ProjectFeature.swift | 9 ++-- graphcode/Tests/CompositeBackendTests.swift | 41 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 graphcode/Tests/CompositeBackendTests.swift diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index e7c6d08d..d13d4ff5 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -811,6 +811,15 @@ public actor GraphStore { goalCache: goalCache, recurrence: effects.recurrence, subGraphDepth: subGraphDepth + 1) + // A loop added inside a composite with no backend named runs on the composite's — + // a Copilot composite must produce Copilot workers, the same rule `createNode` + // applies to a loop fanning out from inside its own session. A creator the tree + // can find still wins, exactly as it would at the top level. + var command = command + if case .createNode(var draft) = command, draft.backend == nil { + draft.backend = draft.createdBy.flatMap { stored($0)?.backend } ?? node.backend + command = .createNode(draft) + } await child.handle(command) // Settled before the write-back and roll-up below, so a client sees the refusal // ahead of the broadcast it would otherwise time out against, and an update's diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index a1baf1a6..bd7585b1 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -867,9 +867,12 @@ extension ProjectFeature { state.draftSchedule = .daily state.draftScheduleTime = "09:00" state.draftSubGraph = nil - // The parent's backend when there is one; the human's default otherwise - // (Settings → Sessions), never a hardcoded one. - state.draftBackend = backend ?? GraphcodeSettingsStore.load().defaultBackend + // The parent's backend when there is one, then the open composite's — its workers + // run on what it runs on — and the human's default otherwise (Settings → Sessions), + // never a hardcoded one. + state.draftBackend = + backend ?? state.openCompositeID.flatMap { state.graph.nodes[id: $0]?.backend } + ?? GraphcodeSettingsStore.load().defaultBackend state.draftWorktree = .none state.draftBranch = "" state.draftParentNodeID = parentNodeID diff --git a/graphcode/Tests/CompositeBackendTests.swift b/graphcode/Tests/CompositeBackendTests.swift new file mode 100644 index 00000000..c9da67af --- /dev/null +++ b/graphcode/Tests/CompositeBackendTests.swift @@ -0,0 +1,41 @@ +import Foundation +import IdentifiedCollections +import Testing + +@testable import GraphcodeKit + +/// Which backend a composite's workers run on. Its own suite because +/// `CompositeAndGlobalGraphTests` sits at the lint budget's type-body limit. +@Suite +struct CompositeBackendTests { + @Test + func aLoopAddedInsideACompositeRunsOnTheCompositesBackend() async { + // A Copilot composite must produce Copilot workers. The draft the app or the CLI + // sends for a loop inside a composite names no backend unless the human picked one, + // and `NodeDraft.effectiveBackend` would have fallen to Claude Code — a composite + // labelled Copilot whose every worker ran a different agent. + let composite = LoopNode( + title: "Triage inbox", loopType: .composite, backend: .copilotCLI, + subGraph: LoopGraph(project: ProjectRef(path: "sub", name: "sub"), nodes: [])) + let store = GraphStore( + graph: LoopGraph(project: ProjectRef(path: "/tmp/p", name: "p"), nodes: [composite])) + + await store.handle( + .subGraphCommand( + nodeID: composite.id, + command: .createNode( + NodeDraft(title: "Classify", loopType: .goalBased, goal: GoalSpec(summary: "sorted"))))) + await store.handle( + .subGraphCommand( + nodeID: composite.id, + command: .createNode( + NodeDraft( + title: "Explicit", loopType: .goalBased, goal: GoalSpec(summary: "sorted"), + backend: .codex)))) + + let workers = await store.graph.nodes[id: composite.id]?.subGraph?.nodes + #expect(workers?.first(where: { $0.title == "Classify" })?.backend == .copilotCLI) + // A backend the human named still wins over the composite's. + #expect(workers?.first(where: { $0.title == "Explicit" })?.backend == .codex) + } +} From bc8e7deb1cbde1b2948fc495d1417f9eb5c9710e Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 2 Sep 2026 19:47:59 -0700 Subject: [PATCH 3/4] Tests: composite on Codex is the refused pairing now that Copilot hosts one Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Pxwzz1AZnVE3peZ8qMejLa --- graphcode/Tests/GraphStoreTests.swift | 7 ++++--- graphcode/Tests/NodeDraftTests.swift | 15 ++++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/graphcode/Tests/GraphStoreTests.swift b/graphcode/Tests/GraphStoreTests.swift index a6f47d98..dc524a50 100644 --- a/graphcode/Tests/GraphStoreTests.swift +++ b/graphcode/Tests/GraphStoreTests.swift @@ -339,11 +339,12 @@ struct GraphStoreTests { // it properly afterward (see `TitleSuggestionClient`). await store.handle(.createNode(NodeDraft(title: "No goal", loopType: .goalBased))) await store.handle(.createNode(NodeDraft(title: "Bare", loopType: .timeBased))) - // A composite on Copilot: sub-agent fan-out is the one capability still unverified - // there, so this is the pairing that stays impossible now that Codex is spiked. + // A composite on Codex: sub-agent fan-out is the one capability still unverified + // there, so this is the pairing that stays impossible. (Copilot's was, until 1.0.80 + // grew `/fleet`.) await store.handle( .createNode( - NodeDraft(title: "Wrong backend", loopType: .composite, backend: .copilotCLI))) + NodeDraft(title: "Wrong backend", loopType: .composite, backend: .codex))) #expect(await store.graph.nodes.isEmpty) } diff --git a/graphcode/Tests/NodeDraftTests.swift b/graphcode/Tests/NodeDraftTests.swift index f00bc156..da08f7d9 100644 --- a/graphcode/Tests/NodeDraftTests.swift +++ b/graphcode/Tests/NodeDraftTests.swift @@ -171,15 +171,16 @@ struct NodeDraftTests { title: "Ship", loopType: .goalBased, goal: GoalSpec(summary: "Tests pass"), backend: .copilotCLI ).isValid) - // Time-based on Copilot is allowed now that it can re-trigger its own session; a - // composite is the pairing that stays refused, since sub-agent fan-out is unverified. + // Time-based on Copilot is allowed now that it can re-trigger its own session, and + // a composite since 1.0.80 grew `/fleet`; a composite on Codex is the pairing that + // stays refused, since sub-agent fan-out is unverified there. #expect( NodeDraft( title: "Poll", loopType: .timeBased, triggerPrompt: "/loop 1h Check", backend: .copilotCLI ).isValid) - #expect( - !NodeDraft(title: "Triage", loopType: .composite, backend: .copilotCLI).isValid) + #expect(NodeDraft(title: "Triage", loopType: .composite, backend: .copilotCLI).isValid) + #expect(!NodeDraft(title: "Triage", loopType: .composite, backend: .codex).isValid) } @Test @@ -227,9 +228,9 @@ struct NodeDraftTests { #expect( CLISessionBackendKind.hosting(.timeBased) == [.claudeCode, .copilotCLI, .codex, .openCode]) - // A composite still needs sub-agent fan-out, which only Claude Code has been shown - // to do. - #expect(CLISessionBackendKind.hosting(.composite) == [.claudeCode]) + // A composite still needs sub-agent fan-out, which Claude Code and (since 1.0.80's + // `/fleet`) Copilot have been shown to do. + #expect(CLISessionBackendKind.hosting(.composite) == [.claudeCode, .copilotCLI]) } @Test From 09431d795573d6e9f94a8f060161ea15e4f761f9 Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 2 Sep 2026 21:13:55 -0700 Subject: [PATCH 4/4] Test: piloting a Copilot composite starts its workers on Copilot Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Pxwzz1AZnVE3peZ8qMejLa --- graphcode/Tests/CompositeBackendTests.swift | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/graphcode/Tests/CompositeBackendTests.swift b/graphcode/Tests/CompositeBackendTests.swift index c9da67af..e5e9d809 100644 --- a/graphcode/Tests/CompositeBackendTests.swift +++ b/graphcode/Tests/CompositeBackendTests.swift @@ -1,3 +1,4 @@ +import ComposableArchitecture import Foundation import IdentifiedCollections import Testing @@ -38,4 +39,31 @@ struct CompositeBackendTests { // A backend the human named still wins over the composite's. #expect(workers?.first(where: { $0.title == "Explicit" })?.backend == .codex) } + @Test + func pilotingACopilotCompositeStartsItsWorkersOnCopilot() async { + // The whole path a human takes: a composite on Copilot, a worker added inside it + // with no backend named, then Pilot. The session the daemon is asked to start + // must be a Copilot one — the composite's label has to be the truth about its + // workers, or the picker allowing the pairing means nothing. + let started = LockIsolated<[LoopNode]>([]) + let composite = LoopNode( + title: "Nightly sweep", loopType: .composite, backend: .copilotCLI, + subGraph: LoopGraph(project: ProjectRef(path: "sub", name: "sub"), nodes: [])) + let store = GraphStore( + graph: LoopGraph(project: ProjectRef(path: "/tmp/p", name: "p"), nodes: [composite]), + onEnsureSession: { node, _ in started.withValue { $0.append(node) } }) + + await store.handle( + .subGraphCommand( + nodeID: composite.id, + command: .createNode( + NodeDraft(title: "Sweep", loopType: .goalBased, goal: GoalSpec(summary: "swept"))))) + #expect(started.value.isEmpty) + + await store.handle(.pilotComposite(composite.id)) + + #expect(started.value.map(\.title) == ["Sweep"]) + #expect(started.value.first?.backend == .copilotCLI) + #expect(await store.graph.nodes[id: composite.id]?.pilotState == .piloted) + } }