diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0e753ced7..0a463b794 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `autoResume` crossing an explicit `/new` boundary: after `/new` a new session's JSONL is created lazily (only once assistant output exists), so exiting before any assistant message left the per-terminal breadcrumb pointing at a not-yet-materialized file. `readTerminalBreadcrumbEntry` rejected the missing target and `continueRecent()` fell back to the most-recent session — the pre-`/new` transcript — processing the next prompt with stale context. `/new` now records a durable `fresh` breadcrumb boundary that `continueRecent()` honors (starting fresh) even when the target is absent, while a genuinely stale/deleted breadcrumb still falls back to the most-recent session ([#5730](https://github.com/can1357/oh-my-pi/issues/5730)). + ## [17.0.1] - 2026-07-16 ### Changed diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index b259dd8c9..2359b8efe 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -445,6 +445,13 @@ export class SessionManager { #inMemoryArtifactCounter = 0; #suppressBreadcrumb = false; + /** + * The last breadcrumb this manager wrote marked a lazy `/new` boundary whose + * JSONL is not yet on disk. Cleared (and the crumb re-stamped non-fresh) once + * the session materializes, so a materialized-then-deleted session still falls + * back to the most-recent session instead of being treated as a fresh crumb. + */ + #breadcrumbFresh = false; #sessionNameChangedCallbacks = new Set<() => void>(); private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) { @@ -457,8 +464,18 @@ export class SessionManager { if (persist && sessionDir) this.#storage.ensureDirSync(sessionDir); } - #rememberBreadcrumb(cwd: string, sessionFile: string): void { - if (!this.#suppressBreadcrumb) writeTerminalBreadcrumb(cwd, sessionFile); + #rememberBreadcrumb(cwd: string, sessionFile: string, fresh = false): void { + this.#breadcrumbFresh = fresh; + if (!this.#suppressBreadcrumb) writeTerminalBreadcrumb(cwd, sessionFile, fresh); + } + + /** + * Re-stamp a fresh `/new` breadcrumb as non-fresh once the session has + * materialized on disk. A no-op unless the current breadcrumb is still fresh. + */ + #materializeBreadcrumb(): void { + if (!this.#breadcrumbFresh || !this.#sessionFile) return; + this.#rememberBreadcrumb(this.#cwd, this.#sessionFile, false); } #clearDiskError(): void { @@ -600,6 +617,7 @@ export class SessionManager { this.#closeWriterEventually(); this.#storage.writeTextSync(this.#sessionFile, body); this.#fileIsCurrent = true; + this.#materializeBreadcrumb(); this.#rewriteRequired = false; this.#hasTitleSlot = true; } catch (err) { @@ -625,6 +643,7 @@ export class SessionManager { async () => { if (await this.#runFencedAtomicRewrite(startEpoch)) { this.#fileIsCurrent = true; + this.#materializeBreadcrumb(); this.#rewriteRequired = false; this.#hasTitleSlot = true; } @@ -807,7 +826,7 @@ export class SessionManager { this.#sessionFile = forcedSessionFile ?? path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl`); - this.#rememberBreadcrumb(this.#cwd, this.#sessionFile); + this.#rememberBreadcrumb(this.#cwd, this.#sessionFile, true); } else { this.#sessionFile = undefined; } @@ -1998,6 +2017,18 @@ export class SessionManager { let chosenSession: string | null | undefined; if (breadcrumb) { + // A fresh `/new` boundary whose JSONL was never materialized (lazy + // new-session persistence, then a process exit before any assistant + // output). Honor the boundary: start fresh rather than falling back to + // findMostRecentSession(), which would resurrect the pre-`/new` + // transcript. A materialized (or genuinely stale/deleted) crumb reports + // exists=false only when fresh, so this never masks a real stale crumb. + if (breadcrumb.fresh && !breadcrumb.exists) { + const manager = new SessionManager(cwd, dir, true, storage); + manager.#resetToNewSession(); + return manager; + } + // Recover stale crumbs: a subagent open (pre-fix) may have pointed this // terminal's breadcrumb at an artifact child; resume the parent instead. breadcrumb.sessionFile = resolveBreadcrumbToInteractiveRoot(breadcrumb.sessionFile); diff --git a/packages/coding-agent/src/session/session-paths.ts b/packages/coding-agent/src/session/session-paths.ts index 79871f800..237aa013e 100644 --- a/packages/coding-agent/src/session/session-paths.ts +++ b/packages/coding-agent/src/session/session-paths.ts @@ -146,28 +146,54 @@ export function computeDefaultSessionDir( * Write a breadcrumb linking the current terminal to a session file. * The breadcrumb contains the cwd and session path so --continue can * find "this terminal's last session" even when running concurrent instances. + * + * `fresh` marks a `/new` (or freshly-minted) session boundary whose JSONL is + * not yet materialized (new-session persistence is lazy until assistant output + * exists). A fresh breadcrumb is honored by {@link readTerminalBreadcrumbEntry} + * even when its target file is still absent, so relaunch/auto-resume reopens the + * post-`/new` session instead of falling back to the pre-`/new` transcript. Once + * the session materializes the caller rewrites the breadcrumb with `fresh:false` + * so a later external delete is still treated as a genuinely stale crumb. */ -export function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void { +export function writeTerminalBreadcrumb(cwd: string, sessionFile: string, fresh = false): void { const terminalId = getTerminalId(); if (!terminalId) return; const breadcrumbDir = getTerminalSessionsDir(); const breadcrumbFile = path.join(breadcrumbDir, terminalId); - const content = `${cwd}\n${sessionFile}\n`; - // Best-effort — don't break session creation if breadcrumb fails - Bun.write(breadcrumbFile, content).catch(() => {}); + const content = fresh ? `${cwd}\n${sessionFile}\nfresh\n` : `${cwd}\n${sessionFile}\n`; + // Synchronous + best-effort. Infrequent (session create/switch/reset, never + // per-append), and writing in order matters: a lazy `/new` fresh crumb is + // re-stamped non-fresh the instant the session materializes, so an async + // fire-and-forget could land the two writes out of order and leave a + // materialized session marked fresh. + try { + fs.mkdirSync(breadcrumbDir, { recursive: true }); + fs.writeFileSync(breadcrumbFile, content); + } catch (err) { + if (!isEnoent(err)) logger.debug("Terminal breadcrumb write failed", { err }); + } } export interface TerminalBreadcrumb { cwd: string; sessionFile: string; + /** The recorded session file exists on disk right now. */ + exists: boolean; + /** Recorded as a `/new` fresh-session boundary whose JSONL may not exist yet. */ + fresh: boolean; } /** * Read the raw terminal breadcrumb for the current terminal. - * Returns the recorded cwd + session file (verified to exist) regardless of - * whether the recorded cwd still matches the current one. Callers decide how - * to interpret a cwd mismatch (e.g. a moved/renamed worktree). + * Returns the recorded cwd + session file regardless of whether the recorded + * cwd still matches the current one. Callers decide how to interpret a cwd + * mismatch (e.g. a moved/renamed worktree). + * + * A missing target file yields `null` UNLESS the breadcrumb is a `fresh` + * boundary — a lazy `/new` session whose JSONL was never written — in which case + * the entry is returned with `exists:false` so the caller can distinguish it + * from a genuinely stale/deleted breadcrumb. */ export async function readTerminalBreadcrumbEntry(): Promise { const terminalId = getTerminalId(); @@ -181,10 +207,13 @@ export async function readTerminalBreadcrumbEntry(): Promise { + let testAgentDir: string; + let cwd: string; + const originalAgentDir = process.env.PI_CODING_AGENT_DIR; + const originalTmuxPane = process.env.TMUX_PANE; + const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); + + beforeEach(async () => { + // Deterministic, non-TTY terminal id so breadcrumb read/write is stable. + process.env.TMUX_PANE = "%new-boundary-test"; + testAgentDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-new-boundary-")); + setAgentDir(testAgentDir); + cwd = path.join(testAgentDir, "project"); + fs.mkdirSync(cwd, { recursive: true }); + }); + + afterEach(async () => { + if (originalTmuxPane === undefined) delete process.env.TMUX_PANE; + else process.env.TMUX_PANE = originalTmuxPane; + if (originalAgentDir) { + setAgentDir(originalAgentDir); + } else { + setAgentDir(fallbackAgentDir); + delete process.env.PI_CODING_AGENT_DIR; + } + await fsp.rm(testAgentDir, { recursive: true, force: true }); + }); + + it("does not resume the pre-/new transcript when the new session produced no output", async () => { + // Persisted old session with recognizable context (assistant output → file on disk). + const old = SessionManager.create(cwd); + old.appendMessage({ role: "user", content: "pre-new work", timestamp: 1 }); + old.appendMessage(makeAssistantMessage()); + await old.flush(); + const oldFile = old.getSessionFile(); + if (!oldFile) throw new Error("Expected persisted old session file"); + await old.close(); + + // Resume it, then hit an explicit `/new` boundary and exit before any + // assistant output — the new session's JSONL is never materialized (lazy). + const resumed = await SessionManager.continueRecent(cwd); + expect(JSON.stringify(resumed.getEntries())).toContain("pre-new work"); + await resumed.newSession(); + const freshFile = resumed.getSessionFile(); + if (!freshFile) throw new Error("Expected a fresh session file path"); + expect(path.resolve(freshFile)).not.toBe(path.resolve(oldFile)); + expect(fs.existsSync(freshFile)).toBe(false); // lazy: not yet on disk + await resumed.close(); + + // Relaunch with auto-resume: must NOT fall back to the pre-/new transcript. + const relaunched = await SessionManager.continueRecent(cwd); + try { + const dump = JSON.stringify(relaunched.getEntries()); + expect(dump).not.toContain("pre-new work"); + expect(relaunched.getEntries()).toHaveLength(0); + // Reopens the fresh session established by `/new`, not the old file. + expect(path.resolve(relaunched.getSessionFile() ?? "")).not.toBe(path.resolve(oldFile)); + } finally { + await relaunched.close(); + } + }); + + it("still falls back to the most-recent session for a genuinely stale breadcrumb", async () => { + // A normal persisted session (survives). + const first = SessionManager.create(cwd); + first.appendMessage({ role: "user", content: "first session", timestamp: 1 }); + first.appendMessage(makeAssistantMessage()); + await first.flush(); + await first.close(); + + // A distinct second session becomes the terminal's breadcrumb target and + // materializes on disk (re-stamped non-fresh), then is externally deleted. + const second = SessionManager.create(cwd); + second.appendMessage({ role: "user", content: "second session", timestamp: 1 }); + second.appendMessage(makeAssistantMessage()); + await second.flush(); + const secondFile = second.getSessionFile(); + if (!secondFile) throw new Error("Expected persisted second session file"); + await second.close(); + await fsp.rm(secondFile, { force: true }); + + const relaunched = await SessionManager.continueRecent(cwd); + try { + // Materialized-then-deleted target (non-fresh) → fall back to the + // most-recent surviving session, not a fresh empty one. + expect(JSON.stringify(relaunched.getEntries())).toContain("first session"); + } finally { + await relaunched.close(); + } + }); +});