Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion GraphcodeKit/Sources/Domain/BackendCapabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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)
Expand Down
9 changes: 9 additions & 0 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions graphcode/Sources/Features/Project/ProjectFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions graphcode/Tests/CompositeBackendTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import ComposableArchitecture
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)
}
@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)
}
}
11 changes: 7 additions & 4 deletions graphcode/Tests/CopilotBackendTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions graphcode/Tests/GraphStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
15 changes: 8 additions & 7 deletions graphcode/Tests/NodeDraftTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading