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: 9 additions & 0 deletions apps/mac/Sources/SergeCodeMac/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,15 @@ struct RootView: View {
ComposerPickerSectionLabel(title: project.name)
.padding(.top, 4)

AlpineMenuRow(
icon: "arrow.triangle.branch",
title: "Branch Current Session",
detail: "Continue from this conversation in a new session"
) {
isPresented.wrappedValue = false
Task { await model.branchThread(thread) }
}

AlpineMenuRow(
title: "New \(thread.provider.displayName) Session",
detail: "Same project and provider as this session"
Expand Down
12 changes: 12 additions & 0 deletions apps/mac/Sources/SergeCodeMac/Model/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1711,6 +1711,18 @@ public final class AppModel {
}
}

@discardableResult
public func branchThread(_ source: ChatThread) async -> ChatThread? {
do {
let thread = try await backend.branchThread(source: source)
selectedThreadID = thread.id
return thread
} catch {
report(error)
return nil
}
}

public func respond(to approval: ApprovalRequest, decision: ApprovalDecision) async {
do {
try await backend.respondToApproval(id: approval.id, decision: decision)
Expand Down
7 changes: 7 additions & 0 deletions apps/mac/Sources/SergeCodeMac/Model/BackendService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public protocol BackendService: Sendable {
/// `title` nil means the backend picks its generic default.
func createThread(projectID: String, provider: ProviderKind, title: String?) async throws
-> ChatThread
func branchThread(source: ChatThread) async throws -> ChatThread
func archiveThread(id: String) async throws
func unarchiveThread(id: String) async throws
func settleThread(id: String) async throws
Expand Down Expand Up @@ -186,6 +187,12 @@ public protocol BackendService: Sendable {
}

public extension BackendService {
func branchThread(source: ChatThread) async throws -> ChatThread {
try await createThread(
projectID: source.projectID, provider: source.provider,
title: "Branch of \(source.title)")
}

func archivedThreadsPage(cursor: Int?, limit: Int) async throws -> ArchivedThreadsPage {
// Default for backends without a separate archived query: paginate
// the full thread list client-side.
Expand Down
25 changes: 25 additions & 0 deletions apps/mac/Sources/SergeCodeMac/Model/LiveBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1651,6 +1651,31 @@ public actor LiveBackend: BackendService {
return thread
}

public func branchThread(source: ChatThread) async throws -> ChatThread {
guard let client = currentClient else { throw LiveBackendError.notConnected }
let threadID = UUID().uuidString
let title = "Branch of \(source.title)"
_ = try await client.branchThread(
sourceThreadId: source.id, threadId: threadID, title: title)
let thread = ChatThread(
id: threadID, projectID: source.projectID, title: title, provider: source.provider,
status: .idle, updatedAt: Date(), latestUserMessageAt: source.latestUserMessageAt,
runtimeMode: source.runtimeMode, interactionMode: source.interactionMode,
modelInstanceID: source.modelInstanceID, modelID: source.modelID,
executorModelInstanceID: source.executorModelInstanceID,
executorModelID: source.executorModelID,
executorMaxSubAgents: source.executorMaxSubAgents,
reasoningEffort: source.reasoningEffort, serviceTier: source.serviceTier)
threadsByID[threadID] = thread
if let selection = modelSelectionsByThread[source.id] {
modelSelectionsByThread[threadID] = selection
}
titleSeedsByThread[threadID] = title
threadEnvByThread[threadID] = threadEnvByThread[source.id]
emitOrdered(threadID: threadID, event: .threadUpserted(thread))
return thread
}

public func archiveThread(id: String) async throws {
guard let client = currentClient else { throw LiveBackendError.notConnected }
_ = try await client.archiveThread(threadId: id)
Expand Down
18 changes: 18 additions & 0 deletions apps/mac/Sources/SergeCodeMac/Model/MockBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ public final class MockBackend: BackendService, @unchecked Sendable {
await state.createThread(projectID: projectID, provider: provider, title: title)
}

public func branchThread(source: ChatThread) async throws -> ChatThread {
await state.branchThread(source: source)
}

public func archiveThread(id: String) async throws {
await state.archiveThread(id: id)
}
Expand Down Expand Up @@ -689,6 +693,20 @@ private actor MockState {
return thread
}

func branchThread(source: ChatThread) -> ChatThread {
var thread = source
thread.id = nextID("thread")
thread.title = "Branch of \(source.title)"
thread.status = .idle
thread.updatedAt = Date()
threadsByID[thread.id] = thread
timelinesByThread[thread.id] = timelinesByThread[source.id] ?? []
diffsByThread[thread.id] = diffsByThread[source.id] ?? []
checkpointsByThread[thread.id] = checkpointsByThread[source.id] ?? []
emit(.threadUpserted(thread))
return thread
}

func archiveThread(id: String) {
guard var thread = threadsByID[id] else { return }
cancelStreamingTasks(for: id)
Expand Down
10 changes: 10 additions & 0 deletions apps/mac/Sources/SergeCodeMac/UI/Shell/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,16 @@ private struct SidebarThreadMenu: View {
}
.disabled(!item.isSelectable || providers.isEmpty)

AlpineMenuRow(
icon: "arrow.triangle.branch",
title: "Branch Session"
) {
Haptics.play(.commit)
dismiss()
Task { await model.branchThread(item.thread) }
}
.disabled(!item.isSelectable)

AlpineMenuSeparator()

lifecycleRow
Expand Down
22 changes: 22 additions & 0 deletions apps/mac/Sources/T3Kit/OrchestrationModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,26 @@ public struct ThreadCreateCommand: Codable, Sendable {
}
}

public struct ThreadBranchCommand: Encodable, Sendable {
public let type: String = "thread.branch"
public var commandId: String
public var sourceThreadId: String
public var threadId: String
public var title: String?
public var createdAt: String

public init(
commandId: String, sourceThreadId: String, threadId: String, title: String? = nil,
createdAt: String
) {
self.commandId = commandId
self.sourceThreadId = sourceThreadId
self.threadId = threadId
self.title = title
self.createdAt = createdAt
}
}

public struct ThreadDeleteCommand: Encodable, Sendable {
public let type: String = "thread.delete"
public var commandId: String
Expand Down Expand Up @@ -1490,6 +1510,7 @@ public enum ClientOrchestrationCommand: Encodable, Sendable {
case projectMetaUpdate(ProjectMetaUpdateCommand)
case projectDelete(ProjectDeleteCommand)
case threadCreate(ThreadCreateCommand)
case threadBranch(ThreadBranchCommand)
case threadDelete(ThreadDeleteCommand)
case threadArchive(ThreadArchiveCommand)
case threadUnarchive(ThreadUnarchiveCommand)
Expand All @@ -1514,6 +1535,7 @@ public enum ClientOrchestrationCommand: Encodable, Sendable {
case .projectMetaUpdate(let c): try container.encode(c)
case .projectDelete(let c): try container.encode(c)
case .threadCreate(let c): try container.encode(c)
case .threadBranch(let c): try container.encode(c)
case .threadDelete(let c): try container.encode(c)
case .threadArchive(let c): try container.encode(c)
case .threadUnarchive(let c): try container.encode(c)
Expand Down
11 changes: 11 additions & 0 deletions apps/mac/Sources/T3Kit/T3Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,17 @@ public actor T3Client {
createdAt: T3Clock.nowISO8601())))
}

@discardableResult
public func branchThread(
sourceThreadId: String, threadId: String, title: String? = nil
) async throws -> DispatchResult {
try await dispatch(
.threadBranch(
ThreadBranchCommand(
commandId: T3Ids.newCommandId(), sourceThreadId: sourceThreadId,
threadId: threadId, title: title, createdAt: T3Clock.nowISO8601())))
}

@discardableResult
public func deleteThread(threadId: String) async throws -> DispatchResult {
try await dispatch(.threadDelete(ThreadDeleteCommand(commandId: T3Ids.newCommandId(), threadId: threadId)))
Expand Down
16 changes: 16 additions & 0 deletions apps/mac/Tests/T3KitTests/ActivityPayloadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,22 @@ struct ActivityPayloadTests {
#expect(interactionObject["interactionMode"] as? String == "plan")
}

@Test("thread branch command encodes source and target identities")
func threadBranchCommand() throws {
let command = ClientOrchestrationCommand.threadBranch(
ThreadBranchCommand(
commandId: "branch-command", sourceThreadId: "source-thread",
threadId: "target-thread", title: "Alternative",
createdAt: "2026-07-04T12:00:00.000Z"))
let data = try WireCoding.encoder.encode(command)
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]

#expect(object["type"] as? String == "thread.branch")
#expect(object["sourceThreadId"] as? String == "source-thread")
#expect(object["threadId"] as? String == "target-thread")
#expect(object["title"] as? String == "Alternative")
}

@Test("tool.completed decodes the typed skill presentation")
func toolPresentationSkill() throws {
let activity = try activity(
Expand Down
1 change: 1 addition & 0 deletions apps/mac/docs/wire-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ discriminated by the `type` string field**. Client-dispatchable members:
| `project.meta.update` | `projectId, title?, workspaceRoot?, defaultModelSelection?, scripts?` | `:476-484` |
| `project.delete` | `projectId, force?` | `:486-491` |
| `thread.create` | `threadId, projectId, title, modelSelection, runtimeMode, interactionMode(dflt "default"), branch:string\|null, worktreePath:string\|null, createdAt` | `:493-507` |
| `thread.branch` | `sourceThreadId, threadId, title?, createdAt`; server snapshots bounded history, remaps message ids, and creates an independent provider session | `packages/contracts/src/orchestration.ts` |
| `thread.delete` | `threadId` | `:509-513` |
| `thread.archive` | `threadId` | `:515-519` |
| `thread.unarchive` | `threadId` | `:521-525` |
Expand Down
97 changes: 97 additions & 0 deletions apps/server/src/mcp/toolkits/agents/ThreadBranchCoordinator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {
BranchThreadError,
type BranchThreadInput,
type BranchThreadResult,
CommandId,
ThreadId,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import { selectThreadBranchContext } from "../../../orchestration/ThreadBranching.ts";
import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
import type { McpInvocationScope } from "../../McpInvocationContext.ts";

export interface ThreadBranchCoordinatorShape {
readonly branch: (
scope: McpInvocationScope,
input: BranchThreadInput,
) => Effect.Effect<BranchThreadResult, BranchThreadError>;
}

export class ThreadBranchCoordinator extends Context.Service<
ThreadBranchCoordinator,
ThreadBranchCoordinatorShape
>()("t3/mcp/toolkits/agents/ThreadBranchCoordinator") {}

const makeThreadBranchCoordinator = Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const engine = yield* OrchestrationEngineService;
const snapshots = yield* ProjectionSnapshotQuery;

const branch: ThreadBranchCoordinatorShape["branch"] = Effect.fn(
"ThreadBranchCoordinator.branch",
)(function* (scope, input) {
const source = yield* snapshots.getThreadDetailById(scope.threadId).pipe(
Effect.mapError(
(cause) =>
new BranchThreadError({
reason: "dispatch-failed",
description: `Failed to read the current thread: ${String(cause)}`,
}),
),
);
if (Option.isNone(source)) {
return yield* new BranchThreadError({
reason: "caller-thread-not-found",
description: "The calling session's thread no longer exists; it cannot be branched.",
});
}

const threadUuid = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
const commandUuid = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
const createdAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
const title = input.title ?? `Branch of ${source.value.title}`;
const context = selectThreadBranchContext(source.value.messages);
const threadId = ThreadId.make(threadUuid);

yield* engine
.dispatch({
type: "thread.branch",
commandId: CommandId.make(`server:thread-branch:${commandUuid}`),
sourceThreadId: scope.threadId,
threadId,
title,
createdAt,
})
.pipe(
Effect.mapError(
(cause) =>
new BranchThreadError({
reason: "dispatch-failed",
description: `Failed to branch the current thread: ${String(cause)}`,
}),
),
);

return {
threadId,
sourceThreadId: scope.threadId,
title,
copiedMessageCount: context.messages.length,
omittedMessageCount: context.omittedMessageCount,
};
});

return ThreadBranchCoordinator.of({ branch });
});

export const ThreadBranchCoordinatorLive = Layer.effect(
ThreadBranchCoordinator,
makeThreadBranchCoordinator,
);
24 changes: 22 additions & 2 deletions apps/server/src/mcp/toolkits/agents/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { DelegateError } from "@t3tools/contracts";
import { BranchThreadError, DelegateError } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as McpInvocationContext from "../../McpInvocationContext.ts";
import { DelegateCoordinator, DelegateCoordinatorLive } from "./DelegateCoordinator.ts";
import { ThreadBranchCoordinator, ThreadBranchCoordinatorLive } from "./ThreadBranchCoordinator.ts";
import { DelegateToolkit } from "./tools.ts";

const requireAgentsScope = Effect.fn("DelegateToolkit.requireAgentsScope")(function* () {
Expand All @@ -17,6 +18,19 @@ const requireAgentsScope = Effect.fn("DelegateToolkit.requireAgentsScope")(funct
return invocation;
});

const requireThreadBranchScope = Effect.fn("DelegateToolkit.requireThreadBranchScope")(
function* () {
const invocation = yield* McpInvocationContext.McpInvocationContext;
if (!invocation.capabilities.has("agents")) {
return yield* new BranchThreadError({
reason: "capability-unavailable",
description: "This MCP credential does not grant the agents capability.",
});
}
return invocation;
},
);

const DelegateToolkitHandlers = DelegateToolkit.toLayer({
delegate_task: (input) =>
Effect.gen(function* () {
Expand All @@ -30,8 +44,14 @@ const DelegateToolkitHandlers = DelegateToolkit.toLayer({
const coordinator = yield* DelegateCoordinator;
return yield* coordinator.waitForDelegate(scope, input);
}),
branch_thread: (input) =>
Effect.gen(function* () {
const scope = yield* requireThreadBranchScope();
const coordinator = yield* ThreadBranchCoordinator;
return yield* coordinator.branch(scope, input);
}),
});

export const DelegateToolkitHandlersLive = DelegateToolkitHandlers.pipe(
Layer.provide(DelegateCoordinatorLive),
Layer.provide(Layer.mergeAll(DelegateCoordinatorLive, ThreadBranchCoordinatorLive)),
);
Loading
Loading