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
15 changes: 13 additions & 2 deletions src-tauri/src/console/pty/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,13 +390,24 @@ pub(crate) fn pty_create(
// The session harness (#1078 P0): ClaudeCodeAdapter is the only impl today; it reproduces the
// exact launch behavior this block had inline. bsc-agent becomes a second adapter (P2).
let has_history = harness.detect_history(&cwd);
let launch = match plan_launch(
let plan = plan_launch(
startup_prompt.as_deref(),
init_cmd.as_deref(),
has_history,
continue_session.unwrap_or(false),
startup_prompt_fresh_only.unwrap_or(false),
) {
);
// #2396: make the resume decision visible — an "always fresh" regression (a caller dropping the
// resume init/flag) shows up in the logs as `resumed=false` right next to `has_history=true`.
// An Init launch resumes when its command carries `--continue` AND there's history to continue
// (the `claude --continue || claude` chain falls back to fresh on its own otherwise).
let resumed = match &plan {
LaunchPlan::Prompt { resume } => *resume,
LaunchPlan::Init(s) => s.contains("--continue") && has_history,
LaunchPlan::None => false,
};
log::info!("pty[{pane_id}] launch decision · has_history={has_history} · resumed={resumed}");
let launch = match plan {
LaunchPlan::Prompt { resume } => Some(harness.launch_command(startup_prompt.as_deref().unwrap_or(""), resume)),
LaunchPlan::Init(s) => Some(s),
LaunchPlan::None => None,
Expand Down
2 changes: 2 additions & 0 deletions src/features/planner/session/plannerLaunch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ describe("plannerLaunchConfig", () => {
expect(l.providerId).toBeUndefined();
expect(l.initCmd).toContain("claude");
expect(l.startupPromptFreshOnly).toBe(true);
// Defensive (#2396): resume is requested explicitly; the backend ANDs it with real history.
expect(l.continueSession).toBe(true);
expect(l.env).toEqual(GH); // no BSC_AGENT_* env for a Claude planner
});

Expand Down
9 changes: 7 additions & 2 deletions src/features/planner/session/plannerLaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ export interface PlannerLaunch {
/** Whether the intro is suppressed on a resumed session (#1240). Claude keeps the fresh-only
* greeting; bsc-agent (a one-shot agent loop, not a REPL) always bakes the intro as its task. */
startupPromptFreshOnly: boolean;
/** Request conversation resume — only meaningful for bsc-agent (its $BSC_AGENT_SESSION continuity);
* the backend ANDs it with "history actually exists". */
/** Request conversation resume — the backend ANDs it with "history actually exists", so a fresh
* project is unaffected (Claude resumes via `--continue`; bsc-agent via its $BSC_AGENT_SESSION
* continuity). */
continueSession?: boolean;
}

Expand All @@ -50,6 +51,10 @@ export function plannerLaunchConfig(s: AppStore, ghEnv: Record<string, string>):
initCmd: "claude --continue 2>/dev/null || claude",
env: ghEnv,
startupPromptFreshOnly: true,
// Defensive (#2396): request resume explicitly. With the fresh-only intro guard a baked prompt
// only ever fires when there's no history (where resume is a no-op), but if a caller ever bakes
// a prompt onto a cwd WITH history this resumes it instead of silently forking a fresh session.
continueSession: true,
};
}
// The planner sees every installed MCP server (#1054); pass them to the runtime as $BSC_AGENT_MCP.
Expand Down
59 changes: 58 additions & 1 deletion src/features/planner/session/usePlanningSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { MutableRefObject } from "react";
import { renderHook } from "@testing-library/react";
import { renderHook, act, waitFor } from "@testing-library/react";
import { invoke } from "@tauri-apps/api/core";
import type { Terminal } from "@xterm/xterm";
import { useAppStore } from "@/store";
Expand Down Expand Up @@ -79,4 +79,61 @@ describe("usePlanningSession", () => {
expect(deps.setSwitchOpen).toHaveBeenCalledWith(false);
expect(invokeMock).not.toHaveBeenCalledWith("clear_project_plan_files", expect.anything());
});

// #2396: reopening a project used to start a FRESH planner chat — the restart path hardcoded a
// plain `claude` launch. These pin the resume-by-default launch config (the mount path's trio)
// and the explicit plain-fresh escape hatch the destructive ops use.
describe("handleRestart resume config (#2396)", () => {
// A live-ish terminal so handleRestart runs through to pty_create.
const term = () =>
({ current: { clear: vi.fn(), cols: 80, rows: 24 } as unknown as Terminal }) as MutableRefObject<Terminal | null>;

const ptyCreateArgs = () => {
const call = invokeMock.mock.calls.find(([cmd]) => cmd === "pty_create");
expect(call).toBeDefined();
return call![1] as Record<string, unknown>;
};

beforeEach(() => {
// The default Claude harness; per-command returns so the intro composes and the launch fires.
useAppStore.setState({ llmProvider: "anthropic", fleetHarness: "claude" });
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === "planner_intro_prompt") return "hello, I am the planner";
if (cmd === "setup_workspaces") return { planning_dir: "/hub/proj" };
return null;
});
});

it("resumes by default: --continue init chain + fresh-only intro + resume request", async () => {
const { result } = renderHook(() => usePlanningSession(makeDeps({ termRef: term() })));

await act(() => result.current.handleRestart());

const args = ptyCreateArgs();
expect(args.initCmd).toBe("claude --continue 2>/dev/null || claude");
expect(args.startupPromptFreshOnly).toBe(true);
expect(args.continueSession).toBe(true);
});

it("launches plain-fresh when passed fresh: true (the destructive ops)", async () => {
const { result } = renderHook(() => usePlanningSession(makeDeps({ termRef: term() })));

await act(() => result.current.handleRestart({ fresh: true }));

const args = ptyCreateArgs();
expect(args.initCmd).toBe("claude");
expect(args.startupPromptFreshOnly).toBe(false);
expect(args.continueSession).toBe(false);
});

it("doClearPlan restarts through the plain-fresh path", async () => {
const { result } = renderHook(() => usePlanningSession(makeDeps({ termRef: term() })));

await act(() => result.current.doClearPlan());

// handleRestart is fired void from doClearPlan — wait for the relaunch to land.
await waitFor(() => expect(ptyCreateArgs().initCmd).toBe("claude"));
expect(ptyCreateArgs().continueSession).toBe(false);
});
});
});
32 changes: 22 additions & 10 deletions src/features/planner/session/usePlanningSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// from Planning.tsx. Owns the `restarting` flag and the four lifecycle operations:
// • regenerateWorkspace — rewrite CLAUDE.md + the context baseline for the CURRENT blueprint
// version WITHOUT touching plan section files (shared by restart + "keep files").
// • handleRestart — kill the PTY, regenerate, and re-spawn `claude` with a fresh intro (#1240).
// • handleRestart — kill the PTY, regenerate, and re-spawn `claude`. Resume-capable by
// default (#2396); the destructive ops pass `fresh: true` for a brand-new session (#1240).
// • keepPlanFiles — adopt the new blueprint/template version on disk, clearing the staleness
// WITHOUT wiping plan files or restarting into a destructive reconciliation (#827).
// • doClearPlan — delete on-disk plan files FIRST (awaited), wipe the store, unlink repos,
Expand Down Expand Up @@ -47,7 +48,7 @@ export interface PlanningSessionDeps {

export interface PlanningSession {
restarting: boolean;
handleRestart: () => Promise<void>;
handleRestart: (opts?: { fresh?: boolean }) => Promise<void>;
keepPlanFiles: () => Promise<void>;
doClearPlan: () => Promise<void>;
doSwitchBlueprint: (targetId: string) => Promise<void>;
Expand Down Expand Up @@ -95,7 +96,14 @@ export function usePlanningSession(deps: PlanningSessionDeps): PlanningSession {
return paths;
}

async function handleRestart() {
// Relaunch the planner session (#2396). RESUME-CAPABLE by default — the launch mirrors the mount
// path exactly (`claude --continue || claude` + the fresh-only intro guard + a resume request), so
// a non-destructive relaunch (sandbox-toggle flip, reopening a project) continues the prior
// conversation instead of starting over. The destructive ops (clear-plan / switch-blueprint) pass
// `fresh: true` to launch a genuinely NEW session — plain `claude`, re-greeted with the intro even
// though history exists (#1240), since the plan that conversation referred to was just wiped.
async function handleRestart(opts?: { fresh?: boolean }) {
const fresh = opts?.fresh === true;
const term = termRef.current;
if (!term || restarting) return;
setRestarting(true);
Expand All @@ -105,16 +113,15 @@ export function usePlanningSession(deps: PlanningSessionDeps): PlanningSession {
const paths = await regenerateWorkspace();
const token = useAppStore.getState().githubToken;
const ghEnv: Record<string, string> = token ? { GH_TOKEN: token, GITHUB_TOKEN: token } : {};
// A deliberate restart launches a brand-new session — re-greet with the intro (#1240). No
// fresh-only guard here: the user explicitly restarted, so fire it even though history exists.
// The intro rides along on every restart: a `fresh` restart always fires it (#1240 — the user
// wiped the plan, so re-greet even though history exists); the default resume restart keeps the
// fresh-only guard, so a returning user with history resumes quietly instead of being re-greeted.
const introMode = plannerIntroMode({ isAuthoring, isExisting: treatAsExisting });
const introText = await safeInvoke<string>("planner_intro_prompt", { mode: introMode }, "",
(e: unknown) => console.error("planner intro prompt failed:", e));
// Relaunch on the selected harness (Claude Code or bsc-agent for any LLM, incl. a forced
// local/ollama provider), carrying the plan-only role gate + provider/model/MCP via BSC_AGENT_*
// env so a restarted local-model planner keeps the same context, prompt, state, and permissions.
// A restart is a deliberate brand-new session (#1240) — re-greet with the intro and DON'T request
// resume (no `continueSession`), so clear-plan / switch-blueprint start genuinely fresh.
const launch = plannerLaunchConfig(useAppStore.getState(), ghEnv);
// bsc-agent (one-shot) never gets stage 1's directive via runtime injection — bake it into the
// intro so a weak local model begins the stage instead of waiting to be advanced (#qwen).
Expand All @@ -138,8 +145,13 @@ export function usePlanningSession(deps: PlanningSessionDeps): PlanningSession {
cols: term.cols,
rows: term.rows,
cwd: sandbox.cwd,
initCmd: launch.providerId === "bsc-agent" ? launch.initCmd : "claude",
// Default (resume): the harness's own launch — for Claude the `--continue || claude` chain —
// with the fresh-only intro guard + resume request, exactly like the mount path (#2396).
// Destructive (`fresh`): plain `claude` + an always-firing intro, so the session starts over.
initCmd: fresh && launch.providerId !== "bsc-agent" ? "claude" : launch.initCmd,
startupPrompt,
startupPromptFreshOnly: fresh ? false : launch.startupPromptFreshOnly,
continueSession: fresh ? false : launch.continueSession,
env: launch.env,
providerId: launch.providerId,
wslDistro: sandbox.wslDistro,
Expand All @@ -166,7 +178,7 @@ export function usePlanningSession(deps: PlanningSessionDeps): PlanningSession {
store.clearPlan(effectiveProjectId);
store.setActiveProjectRepos([]);
store.setPlanningContext(planningPitch, "");
void handleRestart();
void handleRestart({ fresh: true }); // destructive — the plan is gone, start a NEW session (#2396)
}

// Switch the project to another blueprint (#1281 — any → any other project blueprint, confirmed via
Expand All @@ -181,7 +193,7 @@ export function usePlanningSession(deps: PlanningSessionDeps): PlanningSession {
await safeInvoke("clear_project_plan_files", { projectKey: effectiveProjectId }, undefined, console.error);
store.setActiveProjectRepos([]);
store.setPlanningContext(planningPitch, "");
void handleRestart();
void handleRestart({ fresh: true }); // destructive — new blueprint, start a NEW session (#2396)
}

return { restarting, handleRestart, keepPlanFiles, doClearPlan, doSwitchBlueprint };
Expand Down
Loading