From ee5f75396d208de5679318d4ca6bba317752309e Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 11 Jul 2026 00:00:13 +0800 Subject: [PATCH 1/2] feat: surface backend-switch + fork on telegram and web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the UI gaps for the multi-backend + fork features: - Telegram gained NEITHER provider-switch nor fork; add /provider (list backends / switch the attached session) and /fork ([backend] optional) which branches and auto-attaches to the fork so the next message goes to the branch. Both in the command menu + descriptions. - Web had the ProviderPicker (backend switch) but no fork; add a ForkButton next to it — a plain fork on single-backend daemons, and a 'fork onto ' dropdown on multi-backend ones ('continue this conversation on codex'). Success merges the returned SessionInfo and focuses the fork, same as create. HelpModal gains /fork. Web tests: ForkButton plain fork (no providerId), fork-onto-backend (providerId sent), and inline rejection. Telegram handlers mirror the existing /model + /attach patterns. Daemon suite unchanged. --- src/frontends/telegram/index.ts | 87 ++++++++++++++ web/src/components/HelpModal.tsx | 1 + web/src/components/SessionControls.test.tsx | 57 ++++++++- web/src/components/SessionControls.tsx | 123 +++++++++++++++++++- 4 files changed, 265 insertions(+), 3 deletions(-) diff --git a/src/frontends/telegram/index.ts b/src/frontends/telegram/index.ts index a867245..44e20dd 100644 --- a/src/frontends/telegram/index.ts +++ b/src/frontends/telegram/index.ts @@ -30,6 +30,7 @@ import type { ClaudeConfigResultMsg, DaemonMessage, ModelsListResultMsg, + SessionInfo, SessionMode, SessionSearchResultMsg, ToolInfo, @@ -142,6 +143,8 @@ export class TelegramFrontend implements Frontend { { command: "interrupt", description: "Stop the current turn" }, { command: "mode", description: "Set mode: interactive | auto | autonomous" }, { command: "model", description: "Show or switch the model" }, + { command: "provider", description: "Show or switch the backend: /provider " }, + { command: "fork", description: "Branch this session: /fork [backend]" }, { command: "rotate", description: "Fresh context (memory kept)" }, { command: "rename", description: "Rename the attached session" }, { command: "search", description: "Search across sessions" }, @@ -200,6 +203,8 @@ export class TelegramFrontend implements Frontend { this.#bot.command("rotate", (ctx) => this.#handleRotate(ctx)); this.#bot.command("mode", (ctx) => this.#handleMode(ctx)); this.#bot.command("model", (ctx) => this.#handleModel(ctx)); + this.#bot.command("provider", (ctx) => this.#handleProvider(ctx)); + this.#bot.command("fork", (ctx) => this.#handleFork(ctx)); this.#bot.command("who", (ctx) => this.#handleWho(ctx)); // Capabilities discovery — mirror the web /agents /skills /mcp /hooks. this.#bot.command("agents", (ctx) => this.#handleCapabilities(ctx, "agents")); @@ -666,6 +671,88 @@ export class TelegramFrontend implements Frontend { await ctx.reply(`Model → *${escMd(arg)}*\\.`, { parse_mode: "MarkdownV2" }); } + /** `/provider` — list backends; `/provider ` switches the attached session. */ + async #handleProvider(ctx: Context): Promise { + const state = this.#requireAuth(ctx); + if (!state || !state.attachedSessionId) { + await ctx.reply("Not attached. Use /attach ."); + return; + } + const arg = ctx.message?.text?.split(/\s+/)[1]; + if (!arg) { + const list = this.#manager + .providerIds() + .map((id, i) => `• \`${escMd(id)}\`${i === 0 ? " \\(default\\)" : ""}`) + .join("\n"); + await ctx.reply(`*Backends* \\(use /provider \\\\)\n${list}`, { parse_mode: "MarkdownV2" }); + return; + } + const resp = await this.#manager.handle( + { type: "session.set_provider", id: randomUUID(), sessionId: state.attachedSessionId, providerId: arg }, + state.auth!, + this.#makeClient(state, ctx), + ); + if (resp.type === "response.error") { + await ctx.reply(`Error: ${resp.error}`); + return; + } + await ctx.reply(`Backend → *${escMd(arg)}*\\.`, { parse_mode: "MarkdownV2" }); + } + + /** + * `/fork` — branch the attached session into a new one and switch to it. + * `/fork ` forks onto a different backend in one step + * ("continue this conversation on codex"). The chat auto-attaches to the + * fork so the next message goes to the branch, not the parent. + */ + async #handleFork(ctx: Context): Promise { + const state = this.#requireAuth(ctx); + if (!state || !state.attachedSessionId) { + await ctx.reply("Not attached. Use /attach ."); + return; + } + const providerId = ctx.message?.text?.split(/\s+/)[1]; + const chatId = ctx.chat!.id; + + const resp = await this.#manager.handle( + { + type: "session.fork", + id: randomUUID(), + sessionId: state.attachedSessionId, + ...(providerId ? { providerId } : {}), + }, + state.auth!, + this.#makeClient(state, ctx), + ); + if (resp.type === "response.error") { + await ctx.reply(`Error: ${resp.error}`); + return; + } + const fork = (resp as { data: SessionInfo }).data; + + // Auto-attach to the fork (mirror /attach): detach the parent so its + // stream stops landing here, flush anything buffered, then attach the + // branch. Set attachedSessionId BEFORE the attach call — live broadcasts + // can arrive the moment the daemon registers the client. + if (state.attachedSessionId !== fork.id) { + this.#manager.disconnectClient(state.clientId); + state.relay.flushAndClear(chatId); + } + state.attachedSessionId = fork.id; + state.attachedSessionName = fork.name; + await this.#manager.handle( + { type: "session.attach", id: randomUUID(), sessionId: fork.id }, + state.auth!, + this.#makeClient(state, ctx), + ); + + const onBackend = providerId ? ` on *${escMd(fork.providerId ?? providerId)}*` : ""; + await ctx.reply( + `Forked into *${escMd(fork.name)}*${onBackend}\\. You're on the branch now — the parent is untouched\\.`, + { parse_mode: "MarkdownV2" }, + ); + } + // ── Discovery ───────────────────────────────────────────────────────── async #handleWho(ctx: Context): Promise { diff --git a/web/src/components/HelpModal.tsx b/web/src/components/HelpModal.tsx index 23e271e..9d9b88b 100644 --- a/web/src/components/HelpModal.tsx +++ b/web/src/components/HelpModal.tsx @@ -40,6 +40,7 @@ const COMMANDS: readonly CommandDoc[] = [ { usage: "/mcp", desc: "Show MCP servers + their commands" }, { usage: "/hooks", desc: "Show configured hooks" }, { usage: "/who", desc: "Show the connected ZeroID identity" }, + { usage: "/fork", desc: "Branch the session — optionally continue on another backend" }, { usage: "/export", desc: "Export / share the focused session" }, { usage: "/import", desc: "Import (fork) a session from a share" }, { usage: "/clear", desc: "Clear the prompt box" }, diff --git a/web/src/components/SessionControls.test.tsx b/web/src/components/SessionControls.test.tsx index 40fc34a..2df2079 100644 --- a/web/src/components/SessionControls.test.tsx +++ b/web/src/components/SessionControls.test.tsx @@ -2,7 +2,9 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { render, cleanup, fireEvent, waitFor } from "@solidjs/testing-library"; -const requestMock = vi.hoisted(() => vi.fn(() => Promise.resolve(undefined))); +const requestMock = vi.hoisted(() => + vi.fn<(msg: unknown) => Promise>(() => Promise.resolve(undefined)), +); const authMock = vi.hoisted(() => vi.fn(() => undefined as unknown)); vi.mock("../state/connection", () => ({ send: vi.fn(), @@ -141,3 +143,56 @@ describe("ProviderPicker", () => { expect(await findByText(/mid-turn/)).toBeTruthy(); }); }); + +describe("ForkButton", () => { + const PLAIN_TITLE = "Branch this conversation into a new session (/fork)"; + const MENU_TITLE = + "Branch this conversation — same backend, or continue it on another (/fork [backend])"; + + it("single-backend daemon: a plain fork button that forks in place", async () => { + requestMock.mockResolvedValueOnce({ id: "fork-1", name: "s (fork)", providerId: "claude" }); + mockAuth(["claude"]); + ingestSessionList([sess("claude")]); + focusSession("s"); + const { getByTitle } = render(() => ); + fireEvent.click(getByTitle(PLAIN_TITLE)); + await waitFor(() => + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ type: "session.fork", sessionId: "s" }), + ), + ); + // The plain fork carries NO providerId (same backend). + expect(requestMock.mock.calls[0]![0]).not.toHaveProperty("providerId"); + }); + + it("multi-backend daemon: dropdown offers fork-onto each OTHER backend", async () => { + requestMock.mockResolvedValueOnce({ id: "fork-2", name: "s (fork)", providerId: "codex" }); + mockAuth(["claude", "codex", "pi"]); + ingestSessionList([sess("claude")]); + focusSession("s"); + const { getByTitle, getByText } = render(() => ); + fireEvent.click(getByTitle(MENU_TITLE)); + // The "continue on" section lists the OTHER backends. + expect(getByText("continue on")).toBeTruthy(); + expect(getByText("codex")).toBeTruthy(); + fireEvent.click(getByText("codex")); + await waitFor(() => + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ type: "session.fork", sessionId: "s", providerId: "codex" }), + ), + ); + }); + + it("surfaces a fork rejection inline", async () => { + requestMock.mockImplementationOnce(() => + Promise.reject(new Error("Cannot fork a conductor session")), + ); + mockAuth(["claude", "pi"]); + ingestSessionList([sess("claude")]); + focusSession("s"); + const { getByTitle, getByText, findByText } = render(() => ); + fireEvent.click(getByTitle(MENU_TITLE)); + fireEvent.click(getByText("fork (same backend)")); + expect(await findByText(/Cannot fork/)).toBeTruthy(); + }); +}); diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 5458020..78a937a 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -18,9 +18,14 @@ import { request, send, } from "../state/connection"; -import { focusedSession, removeSession } from "../state/sessions"; +import { + focusSession, + focusedSession, + mergeSession, + removeSession, +} from "../state/sessions"; import { fetchModels, modelCatalog } from "../state/models"; -import type { SessionMode } from "../protocol/types"; +import type { SessionInfo, SessionMode } from "../protocol/types"; import { openExportModal } from "./SessionExportModal"; const MODE_OPTIONS: { value: SessionMode; label: string; hint: string }[] = [ @@ -39,6 +44,7 @@ const SessionControls: Component = () => { + @@ -364,6 +370,119 @@ const ProviderPicker: Component<{ ); }; +/** + * Fork the session (`session.fork`) — branch its conversation into a new + * independent session and focus it. On a multi-backend daemon the dropdown + * also offers "fork onto ", continuing the same conversation on a + * different harness in one step. Uses `request()` so a rejection surfaces + * inline; on success the daemon returns the fork's SessionInfo, which we + * merge into the store and focus (same as create). + */ +const ForkButton: Component<{ + sessionId: string; + current?: string; +}> = (props) => { + const [open, setOpen] = createSignal(false); + const [busy, setBusy] = createSignal(false); + const [error, setError] = createSignal(null); + let rootEl: HTMLDivElement | undefined; + useDismissable(() => rootEl, open, () => setOpen(false)); + const providers = () => authIdentity()?.providers ?? []; + // Backends OTHER than the current one — the "fork onto X" targets. + const otherBackends = () => providers().filter((p) => p !== (props.current ?? providers()[0])); + + const doFork = (providerId?: string) => { + setError(null); + setBusy(true); + request({ + type: "session.fork", + id: newRequestId(), + sessionId: props.sessionId, + ...(providerId ? { providerId } : {}), + }) + .then((data) => { + if (data && typeof data === "object" && "id" in data) { + mergeSession(data as SessionInfo); + focusSession((data as SessionInfo).id); + } + setBusy(false); + setOpen(false); + }) + .catch((e) => { + setError(e instanceof Error ? e.message : String(e)); + setBusy(false); + }); + }; + + return ( + 0} + fallback={ + // Single-backend daemon: a plain fork button, no menu. + + } + > +
+ + +
+ +
+ continue on +
+ + {(id) => ( + + )} + + +
+ {error()} +
+
+
+ Branches the conversation into a new session. The original is + untouched; both continue independently. +
+
+
+
+
+ ); +}; + const DestroyButton: Component<{ sessionId: string; name: string; From 3bb763044594fca5d02aa11dcfa82122db2cb967 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 11 Jul 2026 00:31:29 +0800 Subject: [PATCH 2/2] fix: cover telegram /provider and /fork handlers The telegram frontend's new `#handleProvider` and `#handleFork` handlers had no tests, dropping patch coverage below the 80% gate. Extend the existing telegram-flows harness (real JWT auth + stubbed bot + recording fake manager) with `providerIds()` and a `session.fork` case, and cover every branch: - /provider (no arg) lists backends with the first tagged default - /provider switches the attached session's backend - /provider without an attachment prompts to /attach - /fork branches same-backend and auto-attaches to the branch - /fork continues on another backend in one step - /fork without an attachment prompts to /attach Co-Authored-By: Claude Opus 4.8 --- src/tests/telegram-flows.test.ts | 110 +++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/tests/telegram-flows.test.ts b/src/tests/telegram-flows.test.ts index b2f0c3f..75f49d5 100644 --- a/src/tests/telegram-flows.test.ts +++ b/src/tests/telegram-flows.test.ts @@ -132,12 +132,26 @@ function makeFakeManager() { const manager = { findByName: (name: string) => sessions[name], + providerIds: () => ["claude", "codex", "pi"], disconnectClient: (clientId: string) => { disconnected.push(clientId); }, handle: async (msg: any, _auth: unknown, client: AttachedClient) => { handled.push(msg); switch (msg.type) { + case "session.fork": { + // Branch the parent into a new session; keep the parent's backend + // unless the fork asked for a specific one. + const parent = Object.values(sessions).find((s) => s.id === msg.sessionId); + return { + type: "session.fork.result", + data: { + id: "sess-fork", + name: `${parent?.name ?? "session"} (fork)`, + providerId: msg.providerId ?? "claude", + }, + }; + } case "session.list": return { type: "session.list.result", @@ -525,3 +539,99 @@ describe("Telegram flows — switch, failed re-attach, detach, destroy, search", } }); }); + +describe("Telegram flows — provider switch", () => { + it("/provider with no arg lists the available backends (first = default)", async () => { + const { drive, sent, texts } = await boot(); + + await drive("/attach alpha"); + await until(() => texts().some((t) => t.startsWith("Attached to"))); + + await drive("/provider"); + await until(() => texts().some((t) => t.includes("Backends"))); + + const list = sent().find((c) => String(c.payload.text).includes("Backends"))!; + expect(list.payload.parse_mode).toBe("MarkdownV2"); + expect(list.payload.text).toContain("claude"); + expect(list.payload.text).toContain("codex"); + expect(list.payload.text).toContain("pi"); + // First backend is tagged as the default (escaped MarkdownV2 parens). + expect(list.payload.text).toContain("\\(default\\)"); + }); + + it("/provider switches the attached session's backend", async () => { + const { drive, manager, texts } = await boot(); + + await drive("/attach alpha"); + await until(() => texts().some((t) => t.startsWith("Attached to"))); + + await drive("/provider codex"); + await until(() => texts().some((t) => t.includes("Backend →"))); + + expect( + manager.handled.some( + (m) => + m.type === "session.set_provider" && + m.providerId === "codex" && + m.sessionId === "sess-a", + ), + ).toBe(true); + }); + + it("/provider without an attached session tells the user to attach", async () => { + const { drive, texts } = await boot(); + + await drive("/provider codex"); + await until(() => texts().some((t) => t.startsWith("Not attached"))); + + expect(texts().some((t) => t.includes("Backend →"))).toBe(false); + }); +}); + +describe("Telegram flows — fork", () => { + it("/fork branches the attached session (same backend) and auto-attaches to the branch", async () => { + const { drive, manager, texts } = await boot(); + + await drive("/attach alpha"); + await until(() => texts().some((t) => t.startsWith("Attached to"))); + + await drive("/fork"); + await until(() => texts().some((t) => t.includes("Forked into"))); + + // Fork frame carried NO providerId (parent's backend kept). + const forkMsg = manager.handled.find((m) => m.type === "session.fork"); + expect(forkMsg).toBeDefined(); + expect(forkMsg.sessionId).toBe("sess-a"); + expect(forkMsg.providerId).toBeUndefined(); + + // Auto-attach: parent disconnected, then the fork attached. + expect(manager.disconnected).toContain(`telegram:${ALLOWED_USER}`); + expect( + manager.handled.some((m) => m.type === "session.attach" && m.sessionId === "sess-fork"), + ).toBe(true); + }); + + it("/fork continues the branch on another backend in one step", async () => { + const { drive, manager, texts } = await boot(); + + await drive("/attach alpha"); + await until(() => texts().some((t) => t.startsWith("Attached to"))); + + await drive("/fork codex"); + await until(() => texts().some((t) => t.includes("Forked into"))); + + const forkMsg = manager.handled.find((m) => m.type === "session.fork"); + expect(forkMsg.providerId).toBe("codex"); + // The confirmation names the backend it continued on. + expect(texts().some((t) => t.includes("Forked into") && t.includes("codex"))).toBe(true); + }); + + it("/fork without an attached session tells the user to attach", async () => { + const { drive, manager, texts } = await boot(); + + await drive("/fork"); + await until(() => texts().some((t) => t.startsWith("Not attached"))); + + expect(manager.handled.some((m) => m.type === "session.fork")).toBe(false); + }); +});