Skip to content

Commit 21184bd

Browse files
committed
fix: preserve orientation rename compatibility
1 parent 53d27ed commit 21184bd

8 files changed

Lines changed: 111 additions & 19 deletions

File tree

src/__tests__/batch-command-alias.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,15 @@ test('daemon batch policy leaves canonical and unknown names intact', () => {
3333
assert.equal(normalizeBatchCommandName('press'), 'press');
3434
assert.throws(() => readStructuredBatchCommandName('not-a-command', 1), /not available/);
3535
});
36+
37+
test('non-CLI batch normalization does not drop relaunch implied flags', () => {
38+
assert.equal(normalizeBatchCommandName('relaunch'), 'relaunch');
39+
assert.throws(() => readStructuredBatchCommandName('relaunch', 1), /not available/);
40+
assert.throws(
41+
() =>
42+
readCliBatchStepsJson(
43+
JSON.stringify([{ command: 'relaunch', positionals: ['com.example.app'] }]),
44+
),
45+
/not available/,
46+
);
47+
});

src/batch-policy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { deriveStructuredBatchCommandNames } from './core/command-descriptor/derive.ts';
22
import { commandDescriptors } from './core/command-descriptor/registry.ts';
3-
import { normalizeCommandAlias } from './command-aliases.ts';
3+
import { normalizeCrossSurfaceCommandAlias } from './command-aliases.ts';
44
import { AppError } from './kernel/errors.ts';
55

66
/**
@@ -62,7 +62,7 @@ export function normalizeBatchCommandName(command: unknown): string {
6262
// map so structured batch data carrying a renamed command validates and runs
6363
// against the canonical descriptor instead of failing as unavailable.
6464
const raw = typeof command === 'string' ? command.trim().toLowerCase() : '';
65-
return raw ? normalizeCommandAlias(raw) : '';
65+
return raw ? normalizeCrossSurfaceCommandAlias(raw) : '';
6666
}
6767

6868
export function readStructuredBatchCommandName(

src/cli/batch-steps.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { type SessionRuntimeHints } from '../kernel/contracts.ts';
33
import { parseBatchStepRuntime } from '../batch-contract.ts';
44
import { readInputFromCli } from '../commands/cli-grammar.ts';
55
import { isCommandName, type CommandName } from '../commands/command-metadata.ts';
6-
import { normalizeCommandAlias } from '../command-aliases.ts';
6+
import { normalizeCrossSurfaceCommandAlias } from '../command-aliases.ts';
77
import type { CliFlags } from '../commands/cli-grammar/flag-types.ts';
88
import { AppError } from '../kernel/errors.ts';
99
import { isRecord } from '../utils/parsing.ts';
@@ -70,7 +70,7 @@ function readStructuredBatchStep(
7070
return {
7171
...rest,
7272
// Resolve command-name aliases (e.g. `rotate` -> `orientation`) from the shared map.
73-
command: normalizeCommandAlias(rest.command),
73+
command: normalizeCrossSurfaceCommandAlias(rest.command),
7474
...(runtime === undefined ? {} : { runtime }),
7575
};
7676
}
@@ -96,7 +96,7 @@ function readLegacyCommand(value: unknown, stepNumber: number): CommandName {
9696
const raw = typeof value === 'string' ? value.trim().toLowerCase() : '';
9797
if (!raw) throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} requires command.`);
9898
// Resolve command-name aliases (e.g. `rotate` -> `orientation`) from the shared map.
99-
const command = normalizeCommandAlias(raw);
99+
const command = normalizeCrossSurfaceCommandAlias(raw);
100100
if (isCommandName(command)) return command;
101101
throw new AppError(
102102
'INVALID_ARGS',

src/command-aliases.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,29 @@ export type CommandAlias = {
88
alias: string;
99
command: string;
1010
impliedFlags?: readonly BooleanCliFlagKey[];
11+
/** A previously canonical command name that must work outside CLI parsing. */
12+
crossSurfaceRename?: true;
13+
/** Legacy response discriminant retained for hidden compatibility execution. */
14+
legacyResultAction?: string;
1115
};
1216

13-
// The single source of truth for command-name aliases. It is applied at two
14-
// ingress boundaries so an alias resolves the same way regardless of how a
15-
// command arrives: CLI token parsing (`src/cli/parser/args.ts`) and the daemon
16-
// request boundary (`src/daemon/request-router.ts`), which covers structured
17-
// batch steps, recorded replay data, and older remote clients that send the
18-
// wire command directly. `impliedFlags` is a CLI-parse-only concern.
17+
// The single source of truth for command-name aliases. CLI parsing accepts all
18+
// entries and applies implied flags. Non-CLI command-data boundaries accept only
19+
// cross-surface renames: ordinary CLI aliases such as `relaunch` cannot be
20+
// rewritten safely without also applying their parser-only implied flags.
1921
const COMMAND_ALIASES: readonly CommandAlias[] = [
2022
{ alias: 'long-press', command: 'longpress' },
2123
{ alias: 'metrics', command: 'perf' },
2224
{ alias: 'tap', command: 'press' },
2325
{ alias: 'launch', command: 'open' },
2426
{ alias: 'relaunch', command: 'open', impliedFlags: ['relaunch'] },
2527
// Deprecated: `rotate` collided with the `gesture rotate` two-finger gesture.
26-
{ alias: 'rotate', command: 'orientation' },
28+
{
29+
alias: 'rotate',
30+
command: 'orientation',
31+
crossSurfaceRename: true,
32+
legacyResultAction: 'rotate',
33+
},
2734
];
2835

2936
const aliasByToken: ReadonlyMap<string, CommandAlias> = new Map(
@@ -34,6 +41,11 @@ export function normalizeCommandAlias(command: string): string {
3441
return aliasByToken.get(command.toLowerCase())?.command ?? command;
3542
}
3643

44+
export function normalizeCrossSurfaceCommandAlias(command: string): string {
45+
const entry = aliasByToken.get(command.toLowerCase());
46+
return entry?.crossSurfaceRename === true ? entry.command : command;
47+
}
48+
3749
export function commandAlias(rawCommand: string): CommandAlias | undefined {
3850
return aliasByToken.get(rawCommand.toLowerCase());
3951
}

src/daemon/__tests__/request-router-command-alias.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,22 @@ test('daemon boundary leaves canonical commands untouched', async () => {
9191

9292
expect(mockDispatch.mock.calls[0]?.[1]).toBe('orientation');
9393
});
94+
95+
test('daemon boundary does not rewrite relaunch to plain open without its implied flag', async () => {
96+
const sessionStore = makeSessionStore('agent-device-router-alias-');
97+
sessionStore.set('qa-ios', makeIosSession('qa-ios'));
98+
99+
const handler = createHandler(sessionStore);
100+
await handler({
101+
token: 'test-token',
102+
session: 'qa-ios',
103+
command: 'relaunch',
104+
positionals: ['com.example.app'],
105+
flags: {},
106+
meta: { requestId: 'req-relaunch-alias' },
107+
});
108+
109+
expect(mockDispatch).toHaveBeenCalledTimes(1);
110+
expect(mockDispatch.mock.calls[0]?.[1]).toBe('relaunch');
111+
expect(mockDispatch.mock.calls[0]?.[1]).not.toBe('open');
112+
});

src/daemon/request-router.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
withTargetDeviceResolutionScope,
44
} from '../core/dispatch-resolve.ts';
55
import { AppError, normalizeError, retriableForErrorCode } from '../kernel/errors.ts';
6-
import { normalizeCommandAlias } from '../command-aliases.ts';
6+
import { normalizeCrossSurfaceCommandAlias } from '../command-aliases.ts';
77
import { supportedPlatformsForCommand } from '../core/capabilities.ts';
88
import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts';
99
import type { DaemonArtifactType, DaemonError, ResponseCost } from '../kernel/contracts.ts';
@@ -369,7 +369,7 @@ function enrichDaemonError(command: string, error: DaemonError): DaemonError {
369369
// command reaches its canonical descriptor no matter the ingress path. Returns
370370
// the original request object unchanged when the command is already canonical.
371371
function canonicalizeRequestCommand(req: DaemonRequest): DaemonRequest {
372-
const canonical = normalizeCommandAlias(req.command);
372+
const canonical = normalizeCrossSurfaceCommandAlias(req.command);
373373
return canonical === req.command ? req : { ...req, command: canonical };
374374
}
375375

src/mcp/__tests__/command-tools.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,33 @@ test('MCP command tool executor hides client creation behind an execution adapte
3939
assert.equal(result.content[0]?.text, 'Ran wait');
4040
});
4141

42+
test('MCP executes deprecated rotate calls without advertising a second tool', async () => {
43+
const calls: unknown[] = [];
44+
const executor = createCommandToolExecutor({
45+
createClient: () => ({}) as AgentDeviceClient,
46+
runCommand: async (_client, name, input) => {
47+
calls.push({ name, input });
48+
return {
49+
action: 'orientation',
50+
orientation: 'landscape-left',
51+
message: 'Rotated to landscape-left',
52+
};
53+
},
54+
});
55+
56+
const result = await executor.execute('rotate', { orientation: 'landscape-left' });
57+
58+
assert.deepEqual(calls, [
59+
{ name: 'orientation', input: { orientation: 'landscape-left' } },
60+
]);
61+
assert.deepEqual(result.structuredContent, {
62+
action: 'rotate',
63+
orientation: 'landscape-left',
64+
message: 'Rotated to landscape-left',
65+
});
66+
assert.equal(listCommandTools().some((tool) => tool.name === 'rotate'), false);
67+
});
68+
4269
test('MCP command tool executor renders optimized snapshot text by default', async () => {
4370
const executor = createCommandToolExecutor({
4471
createClient: () => ({}) as AgentDeviceClient,

src/mcp/command-tools.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import {
1010
import { COMMAND_OUTPUT_SCHEMAS } from './command-output-schemas.ts';
1111
import { AppError } from '../kernel/errors.ts';
1212
import { formatToolErrorText, normalizeToolError } from './tool-error.ts';
13+
import {
14+
commandAlias,
15+
normalizeCrossSurfaceCommandAlias,
16+
type CommandAlias,
17+
} from '../command-aliases.ts';
1318

1419
export type ToolResult = {
1520
isError: boolean;
@@ -65,17 +70,28 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C
6570
const refPinsByScope = new Map<string, Map<string, number>>();
6671
return {
6772
execute: async (name, input) => {
68-
if (!isCommandName(name)) {
73+
const canonicalName = normalizeCrossSurfaceCommandAlias(name);
74+
if (!isCommandName(canonicalName)) {
6975
throw new AppError('INVALID_ARGS', `Unknown command tool: ${name}`);
7076
}
77+
const alias = canonicalName === name ? undefined : commandAlias(name);
7178
const config = readMcpToolConfig(input);
7279
const commandInput = stripMcpConfigFields(input);
7380
const scopeKey = readPinScopeKey(config, commandInput);
74-
const pinnedInput = pinPlainRefArguments(name, commandInput, refPinsByScope.get(scopeKey));
81+
const pinnedInput = pinPlainRefArguments(
82+
canonicalName,
83+
commandInput,
84+
refPinsByScope.get(scopeKey),
85+
);
7586
const client = await createClient(deps, config.client);
7687
try {
77-
const result = await (deps.runCommand ?? runCommand)(client, name, pinnedInput);
78-
mergeIssuedRefPins(refPinsByScope, scopeKey, name, result);
88+
const canonicalResult = await (deps.runCommand ?? runCommand)(
89+
client,
90+
canonicalName,
91+
pinnedInput,
92+
);
93+
const result = restoreLegacyAliasResult(alias, canonicalResult);
94+
mergeIssuedRefPins(refPinsByScope, scopeKey, canonicalName, result);
7995
return {
8096
isError: false,
8197
structuredContent: result,
@@ -85,7 +101,7 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C
85101
// Render from the UNPINNED input: the model typed plain refs and
86102
// must never see generation suffixes (zero token cost).
87103
text: renderToolText({
88-
name,
104+
name: canonicalName,
89105
input: commandInput,
90106
result,
91107
outputFormat: config.outputFormat,
@@ -101,6 +117,12 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C
101117
};
102118
}
103119

120+
function restoreLegacyAliasResult(alias: CommandAlias | undefined, result: unknown): unknown {
121+
if (!alias?.legacyResultAction) return result;
122+
const record = asOptionalRecord(result);
123+
return record ? { ...record, action: alias.legacyResultAction } : result;
124+
}
125+
104126
/**
105127
* ADR 0012: a command error is a ref-issuing result — `isError: true`, the
106128
* normalized error as `structuredContent`, and an `available`

0 commit comments

Comments
 (0)