Skip to content

Commit ed78493

Browse files
igorcostaAutohand Evolve
andcommitted
Gate handoff session behind experiment
Add the experimental_handoff feature switch, wire /handoff session through the mobile handoff path, and cover the fork/clone/tree and handoff stories with focused tests. Co-authored-by: Autohand Evolve <code-noreply@autohand.ai>
1 parent 864f46f commit ed78493

13 files changed

Lines changed: 263 additions & 3 deletions

src/commands/go.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export const metadata: SlashCommand = {
2626
implemented: true,
2727
};
2828

29+
export const handoffSessionMetadata: SlashCommand = {
30+
command: '/handoff session',
31+
description: 'handoff this session to the Autohand Code iOS app',
32+
implemented: true,
33+
};
34+
2935
interface GoContext {
3036
sessionManager: SessionManager;
3137
currentSession?: Session;
@@ -37,7 +43,13 @@ interface GoContext {
3743
enqueueInstruction?: (instruction: string) => void;
3844
}
3945

46+
interface HandoffSessionContext extends GoContext {
47+
isFeatureEnabled?: (key: string, localDefault?: boolean) => boolean;
48+
trackFeatureActivation?: (key: string, metadata?: Record<string, unknown>) => void | Promise<void>;
49+
}
50+
4051
const MAX_MOBILE_SNAPSHOT_MESSAGES = 24;
52+
const HANDOFF_FLAG = 'experimental_handoff';
4153

4254
type GoMode = 'queue' | 'steer';
4355

@@ -212,3 +224,14 @@ export async function go(ctx: GoContext, args: string[] = []): Promise<string |
212224
].join('\n');
213225
}
214226
}
227+
228+
export async function handoffSession(ctx: HandoffSessionContext, args: string[] = []): Promise<string | null> {
229+
const localDefault = ctx.config?.features?.experimentalHandoff === true;
230+
const enabled = ctx.isFeatureEnabled?.(HANDOFF_FLAG, localDefault) ?? localDefault;
231+
if (!enabled) {
232+
return `The /handoff session command is behind ${HANDOFF_FLAG}. Run /features enable ${HANDOFF_FLAG}, then /handoff session again. No restart required.`;
233+
}
234+
235+
await ctx.trackFeatureActivation?.(HANDOFF_FLAG, { surface: 'slash_command' });
236+
return go(ctx, args);
237+
}

src/core/agent/AgentCommandRuntime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export function parseAgentSlashCommand(_host: AgentCommandRuntimeHost, input: st
206206
const parts = trimmed.split(/\s+/);
207207

208208
// Check for two-word commands like "/skills install", "/mcp install"
209-
const twoWordCommands = ['/skills install', '/skills new', '/skills use', '/agents new', '/mcp install'];
209+
const twoWordCommands = ['/skills install', '/skills new', '/skills use', '/agents new', '/mcp install', '/handoff session'];
210210
const potentialTwoWord = parts.slice(0, 2).join(' ');
211211

212212
if (twoWordCommands.includes(potentialTwoWord)) {

src/core/slashCommandHandler.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,20 @@ export class SlashCommandHandler {
250250
enqueueInstruction: this.ctx.enqueueInstruction,
251251
}, args);
252252
}
253+
case '/handoff session': {
254+
const { handoffSession } = await import('../commands/go.js');
255+
return handoffSession({
256+
sessionManager: this.ctx.sessionManager,
257+
currentSession: this.ctx.currentSession,
258+
workspaceRoot: this.ctx.workspaceRoot,
259+
model: this.ctx.model,
260+
provider: this.ctx.provider,
261+
config: this.ctx.config,
262+
enqueueInstruction: this.ctx.enqueueInstruction,
263+
isFeatureEnabled: this.ctx.isFeatureEnabled,
264+
trackFeatureActivation: this.ctx.trackFeatureActivation,
265+
}, args);
266+
}
253267
case '/chrome': {
254268
const { chrome } = await import('../commands/chrome.js');
255269
return chrome(this.ctx, args);

src/core/slashCommands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export const SLASH_COMMANDS: SlashCommand[] = ([
108108
automode.metadata,
109109
share.metadata,
110110
goCmd.metadata,
111+
goCmd.handoffSessionMetadata,
111112
sync.metadata,
112113
addDir.metadata,
113114
language.metadata,

src/features/featureRegistry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ export const FEATURE_REGISTRY: readonly FeatureDefinition[] = [
173173
configPath: 'features.experimentalClone',
174174
defaultEnabled: false,
175175
},
176+
{
177+
id: 'experimental_handoff',
178+
label: 'Experimental handoff',
179+
description: 'Enable handoff session commands for continuing work from another Autohand surface.',
180+
stage: 'experimental',
181+
configPath: 'features.experimentalHandoff',
182+
defaultEnabled: false,
183+
},
176184
{
177185
id: 'chrome_integration',
178186
label: 'Chrome integration',

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ export interface FeatureFlagSettings {
316316
experimentalFork?: boolean;
317317
/** Enable the experimental /clone session duplication surface. */
318318
experimentalClone?: boolean;
319+
/** Enable the experimental /handoff session surface. */
320+
experimentalHandoff?: boolean;
319321
}
320322

321323
export type PermissionMode = 'interactive' | 'unrestricted' | 'restricted' | 'external';

tests/commands/features.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,21 @@ describe('/experiments command', () => {
9191
expect(mockSaveConfig).toHaveBeenCalledWith(config);
9292
});
9393

94+
it('lets users enable experimental_handoff without requiring restart', async () => {
95+
const { features } = await import('../../src/commands/features.js');
96+
const config = makeConfig({
97+
features: {
98+
experimentalHandoff: false,
99+
},
100+
});
101+
102+
const output = await features({ config }, ['enable', 'experimental_handoff']);
103+
104+
expect(output).toBe('Enabled experimental_handoff.');
105+
expect(config.features?.experimentalHandoff).toBe(true);
106+
expect(mockSaveConfig).toHaveBeenCalledWith(config);
107+
});
108+
94109
it('enables usage_v2 on the active config without requiring restart', async () => {
95110
const { features } = await import('../../src/commands/features.js');
96111
const { usage } = await import('../../src/commands/usage.js');

tests/commands/go.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66
import { describe, expect, it, vi } from 'vitest';
77
import stripAnsi from 'strip-ansi';
8-
import { go } from '../../src/commands/go.js';
8+
import { go, handoffSession } from '../../src/commands/go.js';
99
import { stopMobileRelay } from '../../src/mobile/MobileRelay.js';
1010
import type { MobileHandoffClientLike } from '../../src/mobile/MobileHandoffClient.js';
1111
import type { Session, SessionManager } from '../../src/session/SessionManager.js';
@@ -260,3 +260,71 @@ describe('/go command', () => {
260260
stopMobileRelay();
261261
});
262262
});
263+
264+
describe('/handoff session command', () => {
265+
it('stays behind experimental_handoff by default', async () => {
266+
const client: MobileHandoffClientLike = {
267+
getDeviceId: vi.fn(),
268+
registerDevice: vi.fn(),
269+
sendRelayHeartbeat: vi.fn(),
270+
createPairing: vi.fn(),
271+
claimWork: vi.fn(),
272+
};
273+
274+
const result = await handoffSession({
275+
sessionManager: createSessionManager(createSession()),
276+
workspaceRoot: '/Users/test/project',
277+
model: 'gpt-5.3-codex',
278+
config: {
279+
configPath: '/tmp/config.json',
280+
auth: { token: 'token', user: { id: 'user-1', email: 'user@example.com', name: 'User' } },
281+
},
282+
client,
283+
});
284+
285+
expect(stripAnsi(result || '')).toContain('experimental_handoff');
286+
expect(client.createPairing).not.toHaveBeenCalled();
287+
});
288+
289+
it('creates a handoff after experimental_handoff is enabled', async () => {
290+
const client: MobileHandoffClientLike = {
291+
getDeviceId: vi.fn().mockResolvedValue('device-1'),
292+
registerDevice: vi.fn().mockResolvedValue(undefined),
293+
sendRelayHeartbeat: vi.fn().mockResolvedValue(undefined),
294+
createPairing: vi.fn().mockResolvedValue({
295+
id: 'pairing-1',
296+
pairingUrl: 'https://autohand.ai/code/go?pairing=pairing-1&token=secret',
297+
expiresAt: '2026-05-13T00:10:00.000Z',
298+
pollIntervalMs: 2000,
299+
session: {
300+
id: 'session-1',
301+
deviceId: 'device-1',
302+
workspacePath: '/Users/test/project',
303+
projectName: 'project',
304+
model: 'gpt-5.3-codex',
305+
provider: 'openai',
306+
},
307+
}),
308+
claimWork: vi.fn().mockResolvedValue(null),
309+
};
310+
const trackFeatureActivation = vi.fn();
311+
312+
const result = await handoffSession({
313+
sessionManager: createSessionManager(createSession()),
314+
workspaceRoot: '/Users/test/project',
315+
model: 'gpt-5.3-codex',
316+
provider: 'openai',
317+
config: {
318+
configPath: '/tmp/config.json',
319+
features: { experimentalHandoff: true },
320+
auth: { token: 'token', user: { id: 'user-1', email: 'user@example.com', name: 'User' } },
321+
},
322+
client,
323+
trackFeatureActivation,
324+
});
325+
326+
expect(stripAnsi(result || '')).toContain('Autohand Code mobile handoff');
327+
expect(client.createPairing).toHaveBeenCalled();
328+
expect(trackFeatureActivation).toHaveBeenCalledWith('experimental_handoff', { surface: 'slash_command' });
329+
});
330+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Autohand AI LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
import { mkdtemp, rm } from 'node:fs/promises';
7+
import os from 'node:os';
8+
import path from 'node:path';
9+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10+
import { cloneSession, forkSession, sessionTree } from '../../src/commands/sessionBranching.js';
11+
import { setFeatureState } from '../../src/features/featureRegistry.js';
12+
import { SessionManager, type Session } from '../../src/session/SessionManager.js';
13+
import type { SlashCommandContext } from '../../src/core/slashCommandTypes.js';
14+
import type { LoadedConfig } from '../../src/types.js';
15+
16+
describe('session branching user stories', () => {
17+
let tempDir: string;
18+
let manager: SessionManager;
19+
20+
beforeEach(async () => {
21+
tempDir = await mkdtemp(path.join(os.tmpdir(), 'autohand-session-branching-story-'));
22+
manager = new SessionManager(tempDir);
23+
await manager.initialize();
24+
});
25+
26+
afterEach(async () => {
27+
await rm(tempDir, { recursive: true, force: true });
28+
});
29+
30+
it('lets a user enable fork and clone, branch from a real session, clone the branch, then inspect the tree', async () => {
31+
const config: LoadedConfig = {
32+
configPath: path.join(tempDir, 'config.json'),
33+
provider: 'openrouter',
34+
};
35+
expect(setFeatureState(config, 'experimental_fork', true).ok).toBe(true);
36+
expect(setFeatureState(config, 'experimental_clone', true).ok).toBe(true);
37+
38+
const source = await manager.createSession('/workspace/project', 'test-model');
39+
await source.append({ role: 'user', content: 'Build the first version', timestamp: '2026-01-01T00:00:00.000Z' });
40+
await source.append({ role: 'assistant', content: 'First version done', timestamp: '2026-01-01T00:00:01.000Z' });
41+
await source.append({ role: 'user', content: 'Try the risky alternative', timestamp: '2026-01-01T00:00:02.000Z' });
42+
await source.append({ role: 'assistant', content: 'Alternative done', timestamp: '2026-01-01T00:00:03.000Z' });
43+
44+
const restoredSessions: string[] = [];
45+
const makeContext = (currentSession?: Session): SlashCommandContext => ({
46+
promptModelSelection: vi.fn(),
47+
createAgentsFile: vi.fn(),
48+
resetConversation: vi.fn(),
49+
sessionManager: manager,
50+
currentSession,
51+
memoryManager: {} as SlashCommandContext['memoryManager'],
52+
permissionManager: {} as SlashCommandContext['permissionManager'],
53+
llm: {} as SlashCommandContext['llm'],
54+
workspaceRoot: '/workspace/project',
55+
model: 'test-model',
56+
config,
57+
restoreSession: async (sessionId: string) => {
58+
restoredSessions.push(sessionId);
59+
},
60+
});
61+
62+
const forkOutput = await forkSession(makeContext(source), ['2']);
63+
const forked = manager.getCurrentSession();
64+
expect(forked).toBeDefined();
65+
expect(forkOutput).toContain('Forked session');
66+
expect(forked?.getMessages().map((message) => message.content)).toEqual([
67+
'Build the first version',
68+
'First version done',
69+
'Try the risky alternative',
70+
]);
71+
72+
const cloneOutput = await cloneSession(makeContext(forked));
73+
const cloned = manager.getCurrentSession();
74+
expect(cloned).toBeDefined();
75+
expect(cloneOutput).toContain('Cloned session');
76+
expect(cloned?.getMessages()).toEqual(forked?.getMessages());
77+
78+
const treeOutput = await sessionTree(makeContext(cloned));
79+
expect(treeOutput).toContain(source.metadata.sessionId);
80+
expect(treeOutput).toContain(forked!.metadata.sessionId);
81+
expect(treeOutput).toContain(cloned!.metadata.sessionId);
82+
expect(treeOutput).toContain('fork at user message 2');
83+
expect(restoredSessions).toEqual([
84+
forked!.metadata.sessionId,
85+
cloned!.metadata.sessionId,
86+
]);
87+
});
88+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Autohand AI LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
import { describe, expect, it } from 'vitest';
7+
import { parseAgentSlashCommand } from '../../../src/core/agent/AgentCommandRuntime.js';
8+
9+
describe('parseAgentSlashCommand', () => {
10+
it('parses /handoff session as a two-word command', () => {
11+
const parsed = parseAgentSlashCommand({} as never, '/handoff session --queue');
12+
13+
expect(parsed).toEqual({
14+
command: '/handoff session',
15+
args: ['--queue'],
16+
});
17+
});
18+
});

0 commit comments

Comments
 (0)