Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
2 changes: 2 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ ade chat steer session-id --text "active-turn context"
ade chat schedules session-id --pause # pause this chat's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm)
ade chat wait session-id --for idle --timeout-ms 600000
ade chat recover session-id --turn turn-id --action nudge # wait | nudge | retry | resume
ade chat handoff session-id --model openai/gpt-5.6-sol --note "focus on tests" # brief handoff; add --target-lane <lane-id> to hand off into another lane
ade chat fork session-id --model openai/gpt-5.6-sol # fork provider history (claude/codex/opencode/droid); stays in source lane
ade chat models --provider codex --json # model order + supported reasoning tiers
ade code
ade code --embedded
Expand Down
36 changes: 36 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2687,6 +2687,42 @@ describe("ADE CLI", () => {
});
});

it("passes --target-lane through a brief handoff and rejects it for fork", () => {
const handoff = expectExecutePlan(buildCliPlan([
"chat",
"handoff",
"chat-1",
"--model",
"openai/gpt-5.5-codex",
"--target-lane",
"lane-42",
]));
expect(handoff.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: {
domain: "chat",
action: "handoffSession",
args: {
sourceSessionId: "chat-1",
targetModelId: "openai/gpt-5.5-codex",
mode: "brief",
targetLaneId: "lane-42",
},
},
});

expect(() =>
buildCliPlan([
"chat",
"fork",
"chat-1",
"openai/gpt-5.5-codex",
"--target-lane",
"lane-42",
]),
).toThrow(/--target-lane is only valid for brief handoffs/);
});

it("builds typed chat rewind and subagent commands", () => {
const rewind = expectExecutePlan(buildCliPlan([
"chat",
Expand Down
7 changes: 7 additions & 0 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,8 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade chat goal <session> --status paused Update a Codex goal status
$ ade chat handoff <session> --model openai/gpt-5.6-sol --note "focus on tests"
Start a new chat with an extra handoff note
$ ade chat handoff <session> --model openai/gpt-5.6-sol --target-lane <lane-id>
Brief handoff into a different lane (same project)
$ ade chat fork <session> --model openai/gpt-5.6-sol
Fork full provider history into a new chat
$ ade chat rewind-files <session> --message <user-message-id> --dry-run
Expand Down Expand Up @@ -6944,6 +6946,10 @@ function buildChatPlan(args: string[]): CliPlan {
firstStandalonePositional(args),
"targetModelId",
);
const targetLaneId = readValue(args, ["--target-lane", "--target-lane-id"]);
if (targetLaneId !== null && mode === "fork") {
throw new CliUsageError("chat fork stays in the source lane; --target-lane is only valid for brief handoffs.");
}
const reasoningEffort = readValue(args, ["--reasoning-effort", "--effort"]);
const fastMode = readFastModeFlag(args);
const permissionMode = readValue(args, ["--permission-mode", "--permissions"]);
Expand All @@ -6963,6 +6969,7 @@ function buildChatPlan(args: string[]): CliPlan {
sourceSessionId: requireSession(),
targetModelId,
mode,
...(targetLaneId !== null ? { targetLaneId } : {}),
...(reasoningEffort !== null ? { reasoningEffort } : {}),
...(fastMode !== undefined ? { fastMode, codexFastMode: fastMode } : {}),
...(permissionMode !== null ? { permissionMode } : {}),
Expand Down
47 changes: 47 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,53 @@ describe("createSyncRemoteCommandService", () => {
});
});

it("forwards fork mode and sourceProvider through the cross-machine handoff bridge", async () => {
const prepareCrossMachineHandoff = vi.fn().mockResolvedValue({
capsule: { handoffId: "handoff-1" },
capsuleFingerprint: "fingerprint-1",
usedFallbackSummary: false,
sanitizedSensitiveContext: false,
});
const preflightCrossMachineDestination = vi.fn().mockResolvedValue({
providerAuthorized: true,
modelAvailable: true,
remoteBranchHeadSha: "a".repeat(40),
existingLaneId: null,
blockingErrors: [],
warnings: [],
forkHandoffSupport: { supported: true },
});
const { service } = createService({
agentChatService: { prepareCrossMachineHandoff, preflightCrossMachineDestination },
});

await service.execute(makePayload("chat.prepareCrossMachineHandoff", {
sourceSessionId: "session-1",
handoffId: "handoff-1",
targetModelId: "openai/gpt-5.5",
mode: "fork",
}));
expect(prepareCrossMachineHandoff).toHaveBeenCalledWith(expect.objectContaining({ mode: "fork" }));

await service.execute(makePayload("chat.preflightCrossMachineDestination", {
targetModelId: "openai/gpt-5.5",
sourceBranchRef: "feature/handoff",
sourceHeadSha: "a".repeat(40),
mode: "fork",
sourceProvider: "claude",
}));
expect(preflightCrossMachineDestination).toHaveBeenCalledWith(
expect.objectContaining({ mode: "fork", sourceProvider: "claude" }),
);

await expect(service.execute(makePayload("chat.preflightCrossMachineDestination", {
targetModelId: "openai/gpt-5.5",
sourceBranchRef: "feature/handoff",
sourceHeadSha: "a".repeat(40),
mode: "resume",
}))).rejects.toThrow("mode must be brief or fork");
});

it("routes github.publishCurrentProject through the GitHub service with validated args", async () => {
const publishCurrentProject = vi.fn().mockResolvedValue({
state: "pushed",
Expand Down
15 changes: 15 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,15 @@ function agentChatParallelLaunchStateKey(projectRoot: string, parentLaneId: stri
return `agent-chat-parallel-launch:${projectRoot}:${parentLaneId}`;
}

function parseHandoffMode(value: unknown, action: string): "brief" | "fork" | undefined {
if (value == null) return undefined;
const parsed = asTrimmedString(value);
if (parsed !== "brief" && parsed !== "fork") {
throw new Error(`${action} mode must be brief or fork.`);
}
return parsed;
}

function parseAgentChatHandoffArgs(value: Record<string, unknown>): AgentChatHandoffArgs {
const handoffNote = asTrimmedString(value.handoffNote);
return {
Expand All @@ -657,6 +666,8 @@ function parseAgentChatHandoffArgs(value: Record<string, unknown>): AgentChatHan
function parseCrossMachineDestinationPreflightArgs(
value: Record<string, unknown>,
): AgentChatCrossMachineDestinationPreflightArgs {
const mode = parseHandoffMode(value.mode, "chat.preflightCrossMachineDestination");
const sourceProvider = asTrimmedString(value.sourceProvider);
return {
targetModelId: requireString(
value.targetModelId,
Expand All @@ -670,6 +681,8 @@ function parseCrossMachineDestinationPreflightArgs(
value.sourceHeadSha,
"chat.preflightCrossMachineDestination requires sourceHeadSha.",
),
...(mode !== undefined ? { mode } : {}),
...(sourceProvider ? { sourceProvider: sourceProvider as AgentChatProvider } : {}),
};
}

Expand Down Expand Up @@ -734,11 +747,13 @@ function parsePrepareCrossMachineHandoffArgs(
const permissionMode = parseEnum("permissionMode", ["default", "auto", "plan", "edit", "full-auto", "config-toml"] as const);
const cursorModeId = parseNullableString("cursorModeId");
const cursorConfigValues = parseConfigValues();
const mode = parseHandoffMode(value.mode, "chat.prepareCrossMachineHandoff");
return {
sourceSessionId: requireString(
value.sourceSessionId,
"chat.prepareCrossMachineHandoff requires sourceSessionId.",
),
...(mode !== undefined ? { mode } : {}),
handoffId: requireString(
value.handoffId,
"chat.prepareCrossMachineHandoff requires handoffId.",
Expand Down
Loading