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
87 changes: 87 additions & 0 deletions src/frontends/telegram/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
ClaudeConfigResultMsg,
DaemonMessage,
ModelsListResultMsg,
SessionInfo,
SessionMode,
SessionSearchResultMsg,
ToolInfo,
Expand Down Expand Up @@ -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 <id>" },
{ 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" },
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -666,6 +671,88 @@ export class TelegramFrontend implements Frontend {
await ctx.reply(`Model → *${escMd(arg)}*\\.`, { parse_mode: "MarkdownV2" });
}

/** `/provider` — list backends; `/provider <id>` switches the attached session. */
async #handleProvider(ctx: Context): Promise<void> {
const state = this.#requireAuth(ctx);
if (!state || !state.attachedSessionId) {
await ctx.reply("Not attached. Use /attach <name>.");
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 \\<id\\>\\)\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 <providerId>` 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<void> {
const state = this.#requireAuth(ctx);
if (!state || !state.attachedSessionId) {
await ctx.reply("Not attached. Use /attach <name>.");
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<void> {
Expand Down
110 changes: 110 additions & 0 deletions src/tests/telegram-flows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 <id> 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 <backend> 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);
});
});
1 change: 1 addition & 0 deletions web/src/components/HelpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
57 changes: 56 additions & 1 deletion web/src/components/SessionControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>>(() => Promise.resolve(undefined)),
);
const authMock = vi.hoisted(() => vi.fn(() => undefined as unknown));
vi.mock("../state/connection", () => ({
send: vi.fn(),
Expand Down Expand Up @@ -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(() => <SessionControls />);
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(() => <SessionControls />);
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(() => <SessionControls />);
fireEvent.click(getByTitle(MENU_TITLE));
fireEvent.click(getByText("fork (same backend)"));
expect(await findByText(/Cannot fork/)).toBeTruthy();
});
});
Loading
Loading