Skip to content

Commit bb187b6

Browse files
thymikeeclaude
andcommitted
fix(fold): report a dispatched pose as unverified when devicectl has no hinge-angle stream
Closes #3581 Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
1 parent f70224a commit bb187b6

7 files changed

Lines changed: 124 additions & 5 deletions

File tree

‎docs/adr/0025-foldable-apple-panels.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ unverified, because the pose it ended in is not the one that was asked for.
252252
The attempt count, the per-read timeout, cancellation, and the open/closed end-stop
253253
rule are unchanged: an unsettled fold costs the same four hinge streams it always did.
254254

255+
A toolchain with no `devicectl device motion` subcommand (devicectl 651.13.5, Xcode 27.1 RC build 27A9275) cannot read the hinge at all, and answers the first read with no sample. The HID command has already been delivered by then, so `fold` refuses with `fold-pose-dispatched-unverified`: sent, not confirmed, distinct from a pose the hinge contradicted. The error carries the lit panel as evidence; panel power cannot separate `half-open` from `open`, so it is never promoted to a verified pose.
256+
255257
## Pose is derived from panel power, and official control does not exist
256258

257259
Apple ships fold state as an **app-side, read-only** API: `UIHinge.status`

‎packages/platform-apple/src/core/__tests__/hinge-angle.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ test('refuses a stream that printed no sample, naming the toolchain gap', async
6666

6767
await expect(readAppleHingeAngle(IOS_TEST_SIMULATOR)).rejects.toMatchObject({
6868
code: 'COMMAND_FAILED',
69-
details: expect.objectContaining({ hint: expect.stringContaining('hinge-angle') }),
69+
details: expect.objectContaining({
70+
reason: 'fold-hinge-angle-unavailable',
71+
hint: expect.stringContaining('hinge-angle'),
72+
}),
7073
});
7174
});

‎packages/platform-apple/src/core/hinge-angle.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { runXcrun } from './tool-provider.ts';
1212
*/
1313
const HINGE_ANGLE_SAMPLE = /Angle:\s*(-?\d+(?:[.,]\d+)?)\s*°/g;
1414

15+
/** The stream printed no sample: the toolchain has no `device motion` subcommand or it reports nothing. */
16+
export const HINGE_ANGLE_UNAVAILABLE_REASON = 'fold-hinge-angle-unavailable';
17+
1518
const HINGE_ANGLE_UNSUPPORTED_HINT =
1619
"This Xcode/CoreDevice toolchain does not stream 'devicectl device motion hinge-angle' for this device. Foldable poses can be verified only through that stream; update Xcode to a version that ships it.";
1720

@@ -56,6 +59,7 @@ export async function readAppleHingeAngle(
5659
stdout: result.stdout,
5760
stderr: result.stderr,
5861
deviceId: device.id,
62+
reason: HINGE_ANGLE_UNAVAILABLE_REASON,
5963
hint: HINGE_ANGLE_UNSUPPORTED_HINT,
6064
}),
6165
);

‎packages/platform-apple/src/foldable/pose.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => {
1515
});
1616

1717
import type { DeviceInfo } from '@agent-device/kernel/device';
18+
import { AppError } from '@agent-device/kernel/errors';
1819
import {
1920
buildInventory,
2021
queryAppleDisplayInventory,
@@ -381,3 +382,70 @@ test.each([
381382
expect(mockHinge).toHaveBeenCalledTimes(2);
382383
},
383384
);
385+
386+
test('reports a dispatched but unverifiable pose when the toolchain has no hinge-angle stream', async () => {
387+
mockInventory
388+
.mockResolvedValueOnce(duoInventory('outer'))
389+
.mockResolvedValueOnce(duoInventory('inner'));
390+
mockHinge.mockRejectedValue(
391+
new AppError('COMMAND_FAILED', 'CoreDevice reported no hinge angle sample', {
392+
reason: 'fold-hinge-angle-unavailable',
393+
}),
394+
);
395+
396+
await expect(setAppleFoldPose(duo, { pose: 'open' })).rejects.toMatchObject({
397+
code: 'COMMAND_FAILED',
398+
details: expect.objectContaining({
399+
reason: 'fold-pose-dispatched-unverified',
400+
requestedPose: 'open',
401+
screen: { display: 'LCD-1', coordinateSpace: 'native-panel', widthPt: 669, heightPt: 951 },
402+
}),
403+
});
404+
expect(mockSend).toHaveBeenCalledWith(duo, 'open', undefined);
405+
expect(mockHinge).toHaveBeenCalledTimes(1);
406+
});
407+
408+
test('does not rewrite other hinge read failures as dispatched-unverified', async () => {
409+
mockInventory.mockResolvedValueOnce(duoInventory('inner'));
410+
mockHinge.mockRejectedValue(new AppError('COMMAND_FAILED', 'devicectl wedged'));
411+
412+
await expect(setAppleFoldPose(duo, { pose: 'open' })).rejects.toMatchObject({
413+
details: expect.not.objectContaining({ reason: 'fold-pose-dispatched-unverified' }),
414+
});
415+
});
416+
417+
test('keeps a mid-settle hinge read failure as the read failure, not a missing toolchain', async () => {
418+
mockInventory.mockResolvedValueOnce(duoInventory('inner'));
419+
mockHinge.mockResolvedValueOnce(100).mockRejectedValueOnce(
420+
new AppError('COMMAND_FAILED', 'CoreDevice reported no hinge angle sample', {
421+
reason: 'fold-hinge-angle-unavailable',
422+
}),
423+
);
424+
425+
await expect(setAppleFoldPose(duo, { pose: 'half-open' })).rejects.toMatchObject({
426+
details: expect.objectContaining({ reason: 'fold-hinge-angle-unavailable' }),
427+
});
428+
});
429+
430+
test('omits the screen from the dispatched-unverified refusal when no panel is lit', async () => {
431+
const noneLit = buildInventory([
432+
panel({ power: 'dark' }),
433+
panel({ name: 'LCD-1', displayId: 3, primary: false, power: 'dark' }),
434+
]);
435+
mockInventory.mockResolvedValueOnce(duoInventory('outer')).mockResolvedValueOnce(noneLit);
436+
mockHinge.mockRejectedValue(
437+
new AppError('COMMAND_FAILED', 'CoreDevice reported no hinge angle sample', {
438+
reason: 'fold-hinge-angle-unavailable',
439+
stderr: "Unknown option '--device'",
440+
}),
441+
);
442+
443+
const failure = await setAppleFoldPose(duo, { pose: 'open' }).catch((error: unknown) => error);
444+
expect(failure).toMatchObject({
445+
details: {
446+
reason: 'fold-pose-dispatched-unverified',
447+
hingeReadFailure: { stderr: "Unknown option '--device'" },
448+
},
449+
});
450+
expect((failure as AppError).details).not.toHaveProperty('screen');
451+
});

‎packages/platform-apple/src/foldable/pose.ts‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
type AppleDeviceDisplay,
1616
type AppleDisplayInventory,
1717
} from '../core/display-inventory.ts';
18-
import { readAppleHingeAngle } from '../core/hinge-angle.ts';
18+
import { HINGE_ANGLE_UNAVAILABLE_REASON, readAppleHingeAngle } from '../core/hinge-angle.ts';
1919
import { requireSimulatorDevice } from '../core/simulator.ts';
2020
import { sendSimulatorFoldPose } from './simulator-hid.ts';
2121

@@ -25,6 +25,9 @@ const FOLDABLE_REQUIRED_HINT =
2525
const INVENTORY_REQUIRED_HINT =
2626
"fold needs 'devicectl device info displays' to tell a foldable from a single-panel simulator; update Xcode to a version that ships the display-information feature.";
2727

28+
const DISPATCHED_UNVERIFIED_HINT =
29+
"The pose request was sent and the device may have followed it, but 'devicectl device motion hinge-angle' is unavailable here, so fold cannot confirm it. Take a screenshot or snapshot to see which panel is lit, or update Xcode to a version that ships the hinge-angle stream.";
30+
2831
const POSE_UNSETTLED_HINT =
2932
'The hinge reached the requested pose and was still moving when the read budget ended. Retry the fold, then read the angle directly with xcrun devicectl device motion hinge-angle --device <udid> --session-timeout 1 --timeout 5 to see whether the simulator holds the pose.';
3033

@@ -58,6 +61,40 @@ export async function setAppleFoldPose(
5861
};
5962
}
6063

64+
function isHingeAngleUnavailable(error: unknown): boolean {
65+
return error instanceof AppError && error.details?.reason === HINGE_ANGLE_UNAVAILABLE_REASON;
66+
}
67+
68+
/**
69+
* The toolchain cannot read the hinge, so the pose is neither confirmed nor refuted. The HID
70+
* command was delivered, though, so the device may already be in the requested pose; the lit
71+
* panel is evidence for the caller to weigh, not a verification (panel power cannot separate
72+
* `half-open` from `open`).
73+
*/
74+
async function dispatchedUnverifiedPoseError(
75+
device: DeviceInfo,
76+
pose: FoldPose,
77+
signal: AbortSignal | undefined,
78+
targetAngle: number | undefined,
79+
cause: unknown,
80+
): Promise<AppError> {
81+
const litPanel = await readLitPanel(device, signal);
82+
return new AppError(
83+
'COMMAND_FAILED',
84+
`${device.name} received the ${pose} pose request, but this toolchain cannot read the hinge angle to verify it`,
85+
{
86+
deviceId: device.id,
87+
requestedPose: pose,
88+
...(targetAngle === undefined ? {} : { targetAngleDegrees: targetAngle }),
89+
reason: 'fold-pose-dispatched-unverified',
90+
...(litPanel ? { screen: screenReport(litPanel) } : {}),
91+
...(cause instanceof AppError ? { hingeReadFailure: cause.details } : {}),
92+
hint: DISPATCHED_UNVERIFIED_HINT,
93+
},
94+
cause,
95+
);
96+
}
97+
6198
function requireFoldableInventory(device: DeviceInfo, inventory: AppleDisplayInventory): void {
6299
if (inventory.unresolved) {
63100
throw new AppError(
@@ -99,7 +136,12 @@ async function awaitHingePose(
99136
for (let attempt = 1; attempt <= IOS_FOLD_POSE_SETTLE_ATTEMPTS; attempt += 1) {
100137
signal?.throwIfAborted();
101138
previous = observed;
102-
observed = await readAppleHingeAngle(device, { signal });
139+
try {
140+
observed = await readAppleHingeAngle(device, { signal });
141+
} catch (error) {
142+
if (observed !== undefined || !isHingeAngleUnavailable(error)) throw error;
143+
throw await dispatchedUnverifiedPoseError(device, pose, signal, targetAngle, error);
144+
}
103145
if (isVerifiedHingePose(observed, previous, pose, targetAngle)) return observed;
104146
}
105147
throw hingeVerificationError(device, pose, observed, previous, targetAngle);

‎src/commands/system/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ Screens are handled for you:
253253
254254
Changing the pose:
255255
agent-device fold closed | half-open | open
256-
fold sends a private HID hinge event inside the selected simulator and then reads the hinge angle back from CoreDevice until it agrees: closed is 0 degrees, open is 180, and half-open is any angle between them (requested at 130 degrees). An angle in that interval only proves the category, so half-open is reported once two consecutive readings both fall inside it and agree within 0.5 degrees. The response reports the verified pose, the hinge angle, and the panel the device now lights with its native panel point size, marked coordinateSpace "native-panel". That point size is the panel's own geometry, not the next snapshot's viewport, so it cannot place a tap: the active app window can differ (a 669x951 inner panel hosts a 951x669 window). A hinge whose last reading is some other pose fails with COMMAND_FAILED and reason fold-pose-unverified, naming the angle CoreDevice still reports; a hinge seen half-open but never at rest fails with reason fold-pose-unsettled, naming the observed and previous angles. A single-panel simulator fails with UNSUPPORTED_OPERATION. A simulator scoped to a non-default set with --ios-simulator-device-set is refused before any hinge is touched, with ${foldGuidance.unsupportedScope}: the HID send honors the set, but CoreDevice's display inventory and hinge-angle readback resolve a scoped simulator as not found, so the pose could not be verified. Run fold without --ios-simulator-device-set (in the default set).
256+
fold sends a private HID hinge event inside the selected simulator and then reads the hinge angle back from CoreDevice until it agrees: closed is 0 degrees, open is 180, and half-open is any angle between them (requested at 130 degrees). An angle in that interval only proves the category, so half-open is reported once two consecutive readings both fall inside it and agree within 0.5 degrees. The response reports the verified pose, the hinge angle, and the panel the device now lights with its native panel point size, marked coordinateSpace "native-panel". That point size is the panel's own geometry, not the next snapshot's viewport, so it cannot place a tap: the active app window can differ (a 669x951 inner panel hosts a 951x669 window). A hinge whose last reading is some other pose fails with COMMAND_FAILED and reason fold-pose-unverified, naming the angle CoreDevice still reports; a hinge seen half-open but never at rest fails with reason fold-pose-unsettled, naming the observed and previous angles; a toolchain without the hinge-angle stream (no devicectl device motion) fails with reason fold-pose-dispatched-unverified, meaning the pose was sent but cannot be confirmed, and carries the lit panel's size as evidence, so take a screenshot or snapshot to check. A single-panel simulator fails with UNSUPPORTED_OPERATION. A simulator scoped to a non-default set with --ios-simulator-device-set is refused before any hinge is touched, with ${foldGuidance.unsupportedScope}: the HID send honors the set, but CoreDevice's display inventory and hinge-angle readback resolve a scoped simulator as not found, so the pose could not be verified. Run fold without --ios-simulator-device-set (in the default set).
257257
Expect a fold to take 10-16 seconds: each hinge read is a five-second devicectl stream, and half-open waits for the hinge to stop moving. Re-snapshot after every fold; refs and coordinates from before it are stale, and the command's message says so.
258258
Timed motion: fold --keyframes '[{"atMs":0,"angle":0},{"atMs":5000,"angle":180}]'. Use 2–64 frames starting at 0ms, increasing integer timestamps up to 60000ms, and angles from 0 to 180. The last timestamp sets motion duration, excluding setup and verification. Equal angles hold; cancellation stops motion. See the fold examples in the command and Node API documentation for trajectories.
259259

‎website/docs/docs/commands.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Fold a foldable device:
114114
- The iOS response names the lit panel and its size, marked `coordinateSpace: "native-panel"`. That size is not the next snapshot's viewport and cannot place a tap.
115115
- iOS only: `--keyframes` plays a timed trajectory instead of a preset, for example `fold --keyframes '[{"atMs":0,"angle":0},{"atMs":1667,"angle":160},{"atMs":3333,"angle":100},{"atMs":5000,"angle":180}]'`. Use 2–64 keyframes starting at 0ms, strictly increasing integer timestamps up to 60,000ms, and angles from 0 to 180°.
116116
- iOS needs an Xcode with the iOS simulator SDK and foldable HID support (verified on Xcode 27.1); Device Hub and Accessibility permission aren't required. The first fold builds a helper cached under `~/.agent-device/fold-helper`; failures report `fold-helper-build-failed` or `fold-hid-dispatch-failed`.
117-
- `COMMAND_FAILED` with `reason: fold-pose-unverified` means the hinge reports another pose; `fold-pose-unsettled` means it reached `half-open` but never came to rest.
117+
- `COMMAND_FAILED` with `reason: fold-pose-unverified` means the hinge reports another pose; `fold-pose-unsettled` means it reached `half-open` but never came to rest; `fold-pose-dispatched-unverified` means the pose was sent but this Xcode has no hinge-angle stream to confirm it, so check the lit panel with a screenshot.
118118
- `UNSUPPORTED_OPERATION` covers single-panel devices (`single-panel-device` on Android) and simulators in a non-default `--ios-simulator-device-set` (`unsupported-device-scope`; use the default set). Physical devices are refused.
119119
- On Android, `fold` confirms the pose from the device state the emulator commits and reports `hingeAngleDegrees` as the hinge sensor reads it: the angle the AVD profile defines for the posture, such as 0°, 90°, and 180° on the Pixel folds or 15°, 90°, and 165° on the generic 7.6in Foldable. It also dismisses the lock screen that folding to the cover display can raise. For panels, poses, and timings, see `agent-device help foldable`.
120120

0 commit comments

Comments
 (0)