Skip to content

feat: typed command results, batch 1 (Phase 2 typed-results)#913

Merged
thymikee merged 2 commits into
mainfrom
feat/typed-results-batch-1
Jun 28, 2026
Merged

feat: typed command results, batch 1 (Phase 2 typed-results)#913
thymikee merged 2 commits into
mainfrom
feat/typed-results-batch-1

Conversation

@thymikee

Copy link
Copy Markdown
Member

Phase 2 typed-results — batch 1

Activates the dormant CommandResultMap spine (src/core/command-descriptor/command-result.ts) for the first time in the public client return surface. Three commands whose daemon handlers return a clean, closed result object are narrowed from the generic Record (CommandRequestResult = DaemonResponseData = Record<string, unknown>) to a precise shape, keyed by their registry literal name and wired through CommandResult<Name>.

Every typed field below was confirmed against a re-read of the handler's literal return (accuracy gate). No field the runtime produces is omitted; no field the runtime doesn't set is invented.

Commands typed

Command New result type Public return change Open/Closed Handler source
boot BootCommandResult devices.boot: Promise<CommandRequestResult>Promise<CommandResult<'boot'>> Closed src/daemon/handlers/session-state.ts:244-254
shutdown ShutdownCommandResult devices.shutdown: Promise<CommandRequestResult>Promise<CommandResult<'shutdown'>> Closed src/daemon/handlers/session-state.ts:309-318
viewport ViewportCommandResult command.viewport: Promise<CommandRequestResult & { width; height }> (open) → Promise<CommandResult<'viewport'>> (closed) Closed src/core/dispatch.ts:347-361 (handleViewportCommand)

Exact shapes

// src/contracts/device.ts
type BootCommandResult = {
  platform: Platform; target: DeviceTarget; device: string; id: string; kind: DeviceKind;
  booted: true;
};
type ShutdownCommandResult = {
  platform: Platform; target: DeviceTarget; device: string; id: string; kind: DeviceKind;
  shutdown: TargetShutdownResult;
};

// src/contracts/viewport.ts
type ViewportCommandResult = { width: number; height: number; message: string };

boot/shutdown handlers return a single fixed object literal (no spread) on their only ok: true path; finalizeDaemonResponse only appends artifacts when a path/artifact field exists, which these never produce, so the data reaches the client unchanged. viewport returns { width, height, ...successText(...) } from handleViewportCommand; it is dispatched via the generic path and — having no androidBlockingDialogGuard — never gets a warning appended, so the wire shape stays closed.

Why only these three (skipped, with reasons)

  • press / fill / longpress — already seeded in CommandResultMap, but the contract types describe the in-process dispatch result, NOT the wire response. The daemon builds the response via buildTouchVisualizationResult (src/daemon/handlers/interaction-touch.ts), which dynamically spreads backendResult, reference-frame fields and successText. Wiring those into the client would lie, so they stay in the map but are not wired this batch.
  • screenshot / snapshot / devices / apps / install / open / close / leases / materializations — already returned as precise normalized types by the client (not Record); nothing to narrow.
  • appState / keyboard / clipboard / wait / alert / back / home / rotate — already have hand-authored (intentionally open) result types.
  • reactNative / prepare / interactions / gestures / perf / logs / network / record / trace / settings / diff — handlers spread dynamic/Record data or platform-specific results; not cleanly closed.

Semver

MINOR / public-SDK type-safety improvement. Public client return types narrow from Record to precise shapes. No exported name removed (ViewportCommandResult relocated to src/contracts/viewport.ts and re-exported; BootCommandResult/ShutdownCommandResult added). No runtime behavior change. Open shapes were kept open; only genuinely closed handler returns were closed.

Further batches follow as more handlers are confirmed to have closed return shapes.

Verification

  • tsc -p tsconfig.json --noEmit → exit 0
  • oxfmt --write + oxlint --deny-warnings (changed files) → exit 0
  • vitest run client core/command-descriptor contracts → 223 passed; plus management/dispatch/daemon-handlers (614) and src/__tests__ public/export suites (329) → all pass
  • Layering guard (git grep "from '.*commands/'" src/daemon src/platforms) → empty

Wire the dormant CommandResultMap spine into the public client return types
for three commands whose daemon handlers return clean, closed result objects:

- boot      -> BootCommandResult     (src/daemon/handlers/session-state.ts)
- shutdown  -> ShutdownCommandResult (src/daemon/handlers/session-state.ts)
- viewport  -> ViewportCommandResult (src/core/dispatch.ts handleViewportCommand)

Each result type mirrors the handler's literal return exactly; the public
client methods narrow from the generic Record (CommandRequestResult) to these
precise shapes. No field removed; shapes that the runtime leaves open stay open.
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.4 MB 1.4 MB 0 B
JS gzip 445.5 kB 445.5 kB 0 B
npm tarball 584.7 kB 586.1 kB +1.4 kB
npm unpacked 2.0 MB 2.0 MB +4.5 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.6 ms 30.3 ms +0.7 ms
CLI --help 52.4 ms 51.9 ms -0.4 ms

Top changed chunks: no changes in the largest emitted chunks.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed + verified the types against the real handler returns — accurate, and a purely type-level change (no runtime risk).

  • boot / shutdown: exact closed object literals at session-state.ts:244-254 / 309-318 — types match field-for-field (success path; error paths throw to the client).
  • viewport: dispatch.ts:347-361 returns { width, height, ...successText(msg) }; successText returns { message } for a non-empty message (always the case here) → { width, height, message }. ✓
  • Confirmed finalizeDaemonResponse (request-finalization.ts) only appends artifacts when a field carries a downloadable path — none of these three do — so the wire data reaches the client unchanged.

Because this only annotates executeCommand<T> return types (runtime data byte-identical), there's no runtime regression risk; static verification against the handlers is the right gate. tsc/lint/tests green, layering guard empty, exported names stable, flagged semver-minor. Nice discipline skipping press/fill/longpress (their wire payload is the dynamic buildTouchVisualizationResult, not the dispatch result). LGTM.

@thymikee

Copy link
Copy Markdown
Member Author

I found one public-surface gap before this merges: the root package export still does not expose the result names that this PR introduces/uses.

AgentDeviceClient is exported from src/index.ts, and after this PR its methods return CommandResult<"boot">, CommandResult<"shutdown">, and CommandResult<"viewport">. But src/index.ts does not export CommandResult, BootCommandResult, ShutdownCommandResult, or ViewportCommandResult. That means consumers of the root package can see these names in emitted declarations but cannot conveniently name/import them from agent-device, and it also undercuts the PR body claim that the concrete result types were added/re-exported.

Please add the relevant type exports to the root public surface before merge. I would include at least the three concrete result types, and probably CommandResult too if public client signatures are going to use it directly.

@thymikee thymikee merged commit a2c47ad into main Jun 28, 2026
20 checks passed
@thymikee thymikee deleted the feat/typed-results-batch-1 branch June 28, 2026 12:46
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-06-28 12:46 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant