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
42 changes: 28 additions & 14 deletions crates/freshell-freshagent/src/terminal_tabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,8 @@ pub(crate) async fn spawn_terminal_pane(
amplifier_stub = Some(ensured);
}
Err(detail) => {
// Fail LOUD: spawning `amplifier resume <id>` without a
// Fail LOUD: spawning `amplifier session resume
// --full-history <id>` without a
// resumable dir would hang a doomed CLI (the exact
// failure mode this feature deletes).
return Err(fail_json(
Expand Down Expand Up @@ -3517,15 +3518,21 @@ mod tests {
}

/// A recording spec whose `resume_args` mirror the REAL amplifier
/// manifest (`extensions/amplifier/freshell.json`: `["resume",
/// "{{sessionId}}"]`, no `--resume` flag) so the recorded argv is the
/// launcher-assigned identity contract's exact `amplifier resume <uuid>`
/// shape (minus argv[0], which the recorder script does not capture).
/// manifest (`extensions/amplifier/freshell.json`: `["session", "resume",
/// "--full-history", "{{sessionId}}"]`) so the recorded argv is the
/// launcher-assigned identity contract's exact `amplifier session resume
/// --full-history <uuid>` shape (minus argv[0], which the recorder script
/// does not capture).
fn amplifier_recording_cli_spec(
argv_file: &std::path::Path,
) -> freshell_platform::CliCommandSpec {
let mut spec = recording_cli_spec("amplifier", argv_file);
spec.resume_args = Some(vec!["resume".to_string(), "{{sessionId}}".to_string()]);
spec.resume_args = Some(vec![
"session".to_string(),
"resume".to_string(),
"--full-history".to_string(),
"{{sessionId}}".to_string(),
]);
spec
}

Expand All @@ -3545,8 +3552,8 @@ mod tests {
/// Task 11 (launcher-assigned identity, REST twin of the WS Task 8
/// contract): a fresh `POST /api/tabs {mode:"amplifier"}` mints the
/// session UUID, pre-creates the on-disk stub BEFORE spawn, spawns
/// `amplifier resume <uuid>`, and promotes the minted id into the
/// broadcast `paneContent.sessionRef` (EDEV-07).
/// `amplifier session resume --full-history <uuid>`, and promotes the
/// minted id into the broadcast `paneContent.sessionRef` (EDEV-07).
#[tokio::test]
async fn create_amplifier_tab_fresh_mints_identity_prestubs_and_spawns_resume_argv() {
let argv_file = unique_argv_file("amplifier-fresh");
Expand All @@ -3570,13 +3577,19 @@ mod tests {
.unwrap()
.is_running(&terminal_id));

// 1) Recorded argv is exactly `resume <uuid>` (the recorder captures
// "$@" — everything after the program itself).
// 1) Recorded argv is exactly `session resume --full-history <uuid>`
// (the recorder captures "$@" — everything after the program itself).
let argv = read_argv_file_eventually(&argv_file).await;
let lines: Vec<&str> = argv.lines().collect();
assert_eq!(lines.len(), 2, "expected `resume <uuid>` argv, got: {argv}");
assert_eq!(lines[0], "resume", "argv: {argv}");
let minted = Uuid::parse_str(lines[1])
assert_eq!(
lines.len(),
4,
"expected `session resume --full-history <uuid>` argv, got: {argv}"
);
assert_eq!(lines[0], "session", "argv: {argv}");
assert_eq!(lines[1], "resume", "argv: {argv}");
assert_eq!(lines[2], "--full-history", "argv: {argv}");
let minted = Uuid::parse_str(lines[3])
.expect("minted amplifier session id must parse as a Uuid")
.to_string();

Expand Down Expand Up @@ -3644,7 +3657,8 @@ mod tests {
}

/// Same-id double-resume guard, REST rung: never spawn a second
/// `amplifier resume <sid>` while a live terminal owns <sid>.
/// `amplifier session resume --full-history <sid>` while a live terminal
/// owns <sid>.
#[tokio::test]
async fn create_amplifier_tab_rejects_duplicate_live_resume_with_409() {
let argv_file = unique_argv_file("amplifier-dup");
Expand Down
33 changes: 23 additions & 10 deletions crates/freshell-platform/src/cli_launch_goldens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,7 +750,7 @@ fn amplifier_spec() -> CliCommandSpec {
label: "Amplifier".into(),
env_var: Some("AMPLIFIER_CMD".into()),
default_cmd: "amplifier".into(),
resume_args: Some(s(&["resume", "{{sessionId}}"])),
resume_args: Some(s(&["session", "resume", "--full-history", "{{sessionId}}"])),
base_env,
..Default::default()
}
Expand Down Expand Up @@ -791,8 +791,9 @@ fn g_a1_amplifier_fresh_launch_matches_manifest() {
assert_eq!(launch.label, "Amplifier");
}

/// G-A2 — amplifier resume: `["resume", "<sessionId>"]` from the manifest's
/// `resumeArgs` template (first-occurrence substitution, rev 2.1 pin).
/// G-A2 — amplifier full-history resume: `["session", "resume",
/// "--full-history", "<sessionId>"]` from the manifest's `resumeArgs`
/// template (first-occurrence substitution, rev 2.1 pin).
#[test]
fn g_a2_amplifier_resume_appends_resume_args() {
let mut all_specs = specs();
Expand All @@ -806,7 +807,12 @@ fn g_a2_amplifier_resume_appends_resume_args() {
.unwrap();
assert_eq!(
launch.args,
vec!["resume".to_string(), "sess-123".to_string()]
vec![
"session".to_string(),
"resume".to_string(),
"--full-history".to_string(),
"sess-123".to_string(),
]
);
}

Expand Down Expand Up @@ -848,9 +854,10 @@ fn resolve_amplifier_golden_with_intent(
/// resumeArgs ONLY — `LaunchIntent::Start` with a preallocated session id
/// is a hard StartIntentUnsupported error. The WS/REST pre-create paths
/// therefore keep `LaunchIntent::Resume` for fresh amplifier panes
/// (`amplifier resume <uuid>` of the pre-created stub IS the fresh
/// launch). This golden pins that requirement so a future "make amplifier
/// look like claude" refactor fails loudly here instead of at runtime.
/// (`amplifier session resume --full-history <uuid>` of the pre-created stub
/// IS the fresh launch). This golden pins that requirement so a future "make
/// amplifier look like claude" refactor fails loudly here instead of at
/// runtime.
#[test]
fn g_a4_amplifier_start_intent_without_create_session_args_is_rejected() {
let err = resolve_amplifier_golden_with_intent(
Expand All @@ -866,7 +873,8 @@ fn g_a4_amplifier_start_intent_without_create_session_args_is_rejected() {
}

/// G-A4b: with Resume intent the SAME inputs resolve to
/// `amplifier resume <id>` (the manifest resumeArgs template).
/// `amplifier session resume --full-history <id>` (the manifest resumeArgs
/// template).
#[test]
fn g_a4b_amplifier_resume_intent_with_preallocated_id_resolves_resume_argv() {
let cli = resolve_amplifier_golden_with_intent(
Expand All @@ -876,7 +884,12 @@ fn g_a4b_amplifier_resume_intent_with_preallocated_id_resolves_resume_argv() {
.unwrap();
assert_eq!(
cli.args,
vec!["resume", "11111111-2222-3333-4444-555555555555"]
vec![
"session",
"resume",
"--full-history",
"11111111-2222-3333-4444-555555555555",
]
);
}

Expand Down Expand Up @@ -907,7 +920,7 @@ fn amplifier_manifest_matches_legacy_cli_block() {
assert_eq!(cli["envVar"], "AMPLIFIER_CMD");
assert_eq!(
cli["resumeArgs"],
serde_json::json!(["resume", "{{sessionId}}"])
serde_json::json!(["session", "resume", "--full-history", "{{sessionId}}"])
);
assert_eq!(
cli["env"],
Expand Down
3 changes: 2 additions & 1 deletion crates/freshell-server/src/existence_by_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ pub enum ByIdAnswer {
}

/// Injected by-id amplifier session-dir check (kata 09v1 pattern: the probe
/// must agree with the attach arm — `amplifier resume <id>` finds the dir
/// must agree with the attach arm — `amplifier session resume
/// --full-history <id>` finds the dir
/// regardless of index state). A closure keeps the probe unit-testable;
/// precedent: `ClaudeTranscriptLocator`/`OpencodeSessionLocator`.
pub type AmplifierSessionLocator = Arc<dyn Fn(&str) -> ByIdAnswer + Send + Sync>;
Expand Down
9 changes: 6 additions & 3 deletions crates/freshell-sessions/src/amplifier_stub.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Launcher-assigned amplifier session identity: pre-create ("stub") session
//! dirs on disk so the broker can spawn `amplifier resume <id>` with an
//! dirs on disk so the broker can spawn
//! `amplifier session resume --full-history <id>` with an
//! identity it minted itself — no post-spawn correlation.
//!
//! Unlike [`crate::amplifier`] (read-only indexing; "never mutates provider
Expand Down Expand Up @@ -79,7 +80,8 @@ pub fn resolve_amplifier_home() -> Option<PathBuf> {
/// slug DIFFERENT from slug(canonical cwd), plus that session's own
/// metadata `working_dir`. On a divergent find the caller MUST override the
/// spawn cwd with `working_dir_of_existing` (if it exists and is a dir) or
/// reject the create — `amplifier resume` only searches the spawn cwd's
/// reject the create — `amplifier session resume --full-history` only
/// searches the spawn cwd's
/// slug, so spawning at the requested cwd would silently find nothing.
#[derive(Debug, Clone)]
pub struct EnsuredSession {
Expand All @@ -89,7 +91,8 @@ pub struct EnsuredSession {
pub working_dir_of_existing: Option<String>,
}

/// Make `amplifier resume <session_id>` guaranteed-resumable from `cwd`
/// Make `amplifier session resume --full-history <session_id>`
/// guaranteed-resumable from `cwd`
/// BEFORE spawn. If the session dir already exists under ANY project slug
/// (a real session, or a stub from a previous run), it is found and left
/// untouched — with slug provenance reported (see [`EnsuredSession`]).
Expand Down
3 changes: 2 additions & 1 deletion crates/freshell-terminal/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1960,7 +1960,8 @@ impl TerminalRegistry {

/// Same-id double-resume guard claim (see `resume_create_inflight`'s
/// field doc): reserve a `"resume:{mode}:{sid}"` key for an in-flight
/// amplifier resume create. `false` means another create currently holds
/// resume-mode amplifier create (spawned as `amplifier session resume
/// --full-history <id>`). `false` means another create currently holds
/// it. Mirrors [`Self::begin_keyed_create`]; pair with
/// [`Self::end_resume_create`].
fn begin_resume_create(&self, key: &str) -> bool {
Expand Down
12 changes: 8 additions & 4 deletions crates/freshell-ws/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1420,7 +1420,8 @@ pub(crate) fn build_pty_exit_hook(
// concurrent re-resume that has already passed `ensure_session`
// (found our stub) but has NOT yet inserted its registry row is
// invisible here — its dir can be GC'd in that sub-second window
// and its `amplifier resume <id>` then fails LOUDLY in-terminal;
// and its `amplifier session resume --full-history <id>` then fails
// LOUDLY in-terminal;
// reopening the pane re-stubs the same id (ensure-after-GC).
if freshell_terminal::registry::has_other_live_resume(
&deps.registry.identity_probe_rows(),
Expand Down Expand Up @@ -1510,7 +1511,8 @@ pub(crate) fn derive_launch_prep(create: &TerminalCreate, mode: &str) -> LaunchP
// Launcher-assigned amplifier identity (kata qmpk), the fresh-claude
// preallocation's sibling: a FRESH amplifier pane gets a
// server-minted session id, and (below, in the pre-create block) a
// pre-created stub dir — `amplifier resume <uuid>` of that stub IS
// pre-created stub dir — `amplifier session resume --full-history
// <uuid>` of that stub IS
// the fresh launch. CRITICAL: `launch_intent` STAYS `Resume` —
// amplifier's manifest has resumeArgs only; `Start` without
// createSessionArgs is a hard StartIntentUnsupported error
Expand Down Expand Up @@ -2188,7 +2190,8 @@ pub(crate) async fn handle_create(
let effective_shell = resolve_shell(shell, host_os, is_wsl);
let windows_like = is_windows(host_os) || (is_wsl && effective_shell != ShellType::System);

// Amplifier pre-create (kata qmpk): make `amplifier resume <id>`
// Amplifier pre-create (kata qmpk): make
// `amplifier session resume --full-history <id>`
// guaranteed-resumable BEFORE spawn. Fresh creates get a brand-new stub;
// requested resumes whose dir is gone (e.g. a GC'd never-used stub from
// a previous run) are re-stubbed under the SAME id so restore keeps
Expand Down Expand Up @@ -2352,7 +2355,8 @@ pub(crate) async fn handle_create(
amplifier_stub = Some(ensured);
}
Err(detail) => {
// Fail LOUD: spawning `amplifier resume <id>` without a
// Fail LOUD: spawning `amplifier session resume
// --full-history <id>` without a
// resumable dir would hang a doomed CLI (the exact
// failure mode this feature deletes).
return send_create_error(
Expand Down
4 changes: 2 additions & 2 deletions crates/freshell-ws/tests/amplifier_launcher_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ async fn fresh_amplifier_create_carries_launcher_assigned_session_ref_and_stub()
/// A requested amplifier RESUME whose session dir does not exist (e.g. a
/// GC'd never-used stub from a previous run) is RE-STUBBED under the SAME
/// id before spawn, so restore keeps working instead of hanging a doomed
/// `amplifier resume <id>`.
/// `amplifier session resume --full-history <id>`.
#[tokio::test]
async fn requested_amplifier_resume_with_missing_dir_is_restubbed_under_same_id() {
let amp_home = isolate_amplifier_home();
Expand Down Expand Up @@ -219,7 +219,7 @@ async fn amplifier_create_with_vanished_cwd_is_rejected_before_spawn() {
/// placeholder (the old correlation bug's poisoned persisted tab state) —
/// never a resumable amplifier session. A create carrying one must be
/// rejected LOUDLY before any stub is written, instead of spawning an
/// `amplifier resume terminal:...` that hangs forever.
/// `amplifier session resume --full-history terminal:...` that hangs forever.
#[tokio::test]
async fn amplifier_create_rejects_synthetic_terminal_placeholder_refs() {
let amp_home = isolate_amplifier_home();
Expand Down
2 changes: 1 addition & 1 deletion extensions/amplifier/freshell.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"cli": {
"command": "amplifier",
"envVar": "AMPLIFIER_CMD",
"resumeArgs": ["resume", "{{sessionId}}"],
"resumeArgs": ["session", "resume", "--full-history", "{{sessionId}}"],
"env": { "PROMPT_TOOLKIT_NO_CPR": "1" }
},
"picker": {
Expand Down
2 changes: 1 addition & 1 deletion server/coding-cli/providers/amplifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export const amplifierProvider: CodingCliProvider = {
},

getResumeArgs(sessionId: string) {
return ['resume', sessionId]
return ['session', 'resume', '--full-history', sessionId]
},

parseEvent(line: string): NormalizedEvent[] {
Expand Down
6 changes: 4 additions & 2 deletions test/e2e-browser/fixtures/fake-amplifier-activity-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,13 @@ let eventsPath = null
let sessionId = null
let sessionDir = null

if (argv[0] === 'resume' && argv[1]) {
if (argv[0] === 'session' && argv[1] === 'resume' && argv.length > 2) {
// LAUNCHER-ASSIGNED flow: adopt the broker's pre-created stub instead of
// creating our own session dir (the events lane is already tailing the
// stub's events.jsonl -- records written anywhere else are invisible).
sessionId = argv[1]
// Real launch shape: `session resume --full-history <id>` -- the session
// id is the argument after `--full-history` (i.e. the last element).
sessionId = argv[argv.length - 1]
sessionDir = findSessionDir(sessionId)
if (sessionDir) eventsPath = path.join(sessionDir, 'events.jsonl')
process.stdout.write(`amplifier: resumed session ${sessionId}\r\n`)
Expand Down
6 changes: 4 additions & 2 deletions test/e2e-browser/fixtures/fake-amplifier-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@ function findSessionDir(sessionId) {
return null
}

if (argv[0] === 'resume') {
const sessionId = argv[1] ?? ''
if (argv[0] === 'session' && argv[1] === 'resume') {
// Real launch shape: `session resume --full-history <id>` -- the session
// id is the argument after `--full-history` (i.e. the last element).
const sessionId = argv[argv.length - 1] ?? ''
process.stdout.write(`amplifier: resumed session ${sessionId}\r\n`)
process.stdout.write('amplifier> \r\n')
// Exit cleanly on EOF (Ctrl-D), like the real interactive CLI -- specs use
Expand Down
12 changes: 7 additions & 5 deletions test/e2e-browser/specs/amplifier-restore-rust.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,12 +377,14 @@ test.describe('Amplifier Restore (Rust only)', () => {
expect(await findStubDir(negativeSessionId)).not.toBeNull()

// argv log: every amplifier spawn in this scenario was a resume, and
// both ids appear as `resume <id>` invocations post-restart.
// both ids appear as `session resume --full-history <id>` invocations
// post-restart.
const entries = (await fs.readFile(argLogPath, 'utf8')).trim().split('\n').map((l) => JSON.parse(l) as { argv: string[] })
const resumes = entries.filter((e) => e.argv[0] === 'resume')
expect(resumes.some((e) => e.argv[1] === sessionId)).toBe(true)
expect(resumes.some((e) => e.argv[1] === negativeSessionId)).toBe(true)
expect(entries.every((e) => e.argv[0] === 'resume')).toBe(true)
const isResume = (argv: string[]) => argv[0] === 'session' && argv[1] === 'resume' && argv[2] === '--full-history'
const resumes = entries.filter((e) => isResume(e.argv))
expect(resumes.some((e) => e.argv[3] === sessionId)).toBe(true)
expect(resumes.some((e) => e.argv[3] === negativeSessionId)).toBe(true)
expect(entries.every((e) => isResume(e.argv))).toBe(true)

// Invariant pins: the re-homed identity sweep never fires for these
// launcher-assigned panes, and the boot layout canary stayed quiet.
Expand Down
2 changes: 1 addition & 1 deletion test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ test.describe('MCP QA smoke -- Rust full mode-matrix (QA-lever payoff)', () => {
// At least TWO resume invocations of this id: the create-time spawn
// and the explicit resume tab (every amplifier spawn is a resume now).
const amplifierArgvLines = await readArgvLines(amplifierArgLog)
const amplifierResumeInvocations = amplifierArgvLines.filter((e) => e.argv[0] === 'resume' && e.argv[1] === amplifierSessionId)
const amplifierResumeInvocations = amplifierArgvLines.filter((e) => e.argv[0] === 'session' && e.argv[1] === 'resume' && e.argv[2] === '--full-history' && e.argv[3] === amplifierSessionId)
expect(amplifierResumeInvocations.length).toBeGreaterThanOrEqual(2)

// -----------------------------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions test/e2e-browser/specs/remote-tab-linkage-rust.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,10 @@ test.describe('Remote tab linkage (Rust only)', () => {
// restart proof below asserts a NEW one beyond this count).
const resumesAfterCreate = await expect.poll(async () => {
const lines = await readArgvLog(argLogPath)
return lines.filter((e) => e.argv[0] === 'resume' && e.argv[1] === SEEDED_SESSION_ID).length
return lines.filter((e) => e.argv[0] === 'session' && e.argv[1] === 'resume' && e.argv[2] === '--full-history' && e.argv[3] === SEEDED_SESSION_ID).length
}, { timeout: 20_000 }).toBeGreaterThan(0).then(async () => {
const lines = await readArgvLog(argLogPath)
return lines.filter((e) => e.argv[0] === 'resume' && e.argv[1] === SEEDED_SESSION_ID).length
return lines.filter((e) => e.argv[0] === 'session' && e.argv[1] === 'resume' && e.argv[2] === '--full-history' && e.argv[3] === SEEDED_SESSION_ID).length
})

// ------------------------------------------------------------------
Expand Down Expand Up @@ -315,7 +315,7 @@ test.describe('Remote tab linkage (Rust only)', () => {
// invocation beyond the create-time one(s).
await expect.poll(async () => {
const lines = await readArgvLog(argLogPath)
return lines.filter((e) => e.argv[0] === 'resume' && e.argv[1] === SEEDED_SESSION_ID).length
return lines.filter((e) => e.argv[0] === 'session' && e.argv[1] === 'resume' && e.argv[2] === '--full-history' && e.argv[3] === SEEDED_SESSION_ID).length
}, { timeout: 30_000 }).toBeGreaterThan(resumesAfterCreate)

// And the linkage itself survived: the sidebar row is OPEN again
Expand Down
Loading
Loading