Repository navigation
feat(mobile): read agent status on Apple Watch, CarPlay and StandBy #28394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
|
|
||
| import type { AgentActivityProps } from "../../widgets/AgentActivity"; | ||
|
|
||
| const native = vi.hoisted(() => ({ | ||
| activities: [] as Array<{ end: () => Promise<void> }>, | ||
| started: [] as Array<string>, | ||
| })); | ||
|
|
||
| vi.mock("react-native", () => ({ Platform: { OS: "ios" } })); | ||
| vi.mock("expo-notifications", () => ({ | ||
| requestPermissionsAsync: async () => ({ granted: true, status: "granted" }), | ||
| dismissAllNotificationsAsync: async () => {}, | ||
| })); | ||
| vi.mock("../agent-awareness/androidNotifications", () => ({ | ||
| showAndroidShowcaseAgentActivity: () => true, | ||
| })); | ||
| vi.mock("../agent-awareness/agentLiveActivity", () => ({ | ||
| getAgentLiveActivities: () => [...native.activities], | ||
| startAgentLiveActivity: (props: AgentActivityProps) => { | ||
| const activity = { end: async () => {} }; | ||
| native.started.push(props.title); | ||
| native.activities = [activity]; | ||
| return activity; | ||
| }, | ||
| })); | ||
|
|
||
| const { stageShowcaseAgentActivity } = await import("./stageShowcaseAgentActivity"); | ||
|
|
||
| function fixture(title: string): AgentActivityProps { | ||
| return { | ||
| title, | ||
| subtitle: "Agent work in progress", | ||
| activeCount: 1, | ||
| updatedAt: "2026-10-31T17:42:00.000Z", | ||
| activities: [], | ||
| }; | ||
| } | ||
|
|
||
| describe("stageShowcaseAgentActivity", () => { | ||
| beforeEach(() => { | ||
| native.activities = []; | ||
| native.started = []; | ||
| }); | ||
|
|
||
| it("does not start a replaced fixture after its native end call settles", async () => { | ||
| let finishEnding = () => {}; | ||
| const ending = new Promise<void>((resolve) => (finishEnding = resolve)); | ||
| let endCalls = 0; | ||
| native.activities = [{ end: () => (endCalls++ === 0 ? ending : Promise.resolve()) }]; | ||
| let replaced = false; | ||
|
|
||
| const first = stageShowcaseAgentActivity(fixture("store"), 0, () => replaced); | ||
| await vi.waitFor(() => expect(endCalls).toBe(1)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wait for the end-call milestone without polling. Resolve a promise from the mocked As per coding guidelines, “Never wait on sleeps or polling.” As per path instructions, “Tests must await meaningful milestones rather than sleeps or polling.”
|
||
| replaced = true; | ||
| const second = await stageShowcaseAgentActivity(fixture("handoff-approval"), 0, () => false); | ||
| finishEnding(); | ||
|
|
||
| expect(second).toBe(true); | ||
| expect(await first).not.toBe(true); | ||
| expect(native.started).toEqual(["handoff-approval"]); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,12 +12,15 @@ import { showcaseAndroidActivityData } from "./showcaseAgentActivity"; | |
| export async function stageShowcaseAgentActivity( | ||
| activity: AgentActivityProps, | ||
| now: number, | ||
| // A newer request may replace this one while it awaits; it must not start after that. | ||
| isCancelled: () => boolean, | ||
| ): Promise<true | string> { | ||
| const permission = await Notifications.requestPermissionsAsync({ | ||
| ios: { allowAlert: true, allowBadge: true, allowSound: true }, | ||
| }); | ||
| if (!permission.granted) return `notification permission ${permission.status}`; | ||
| await Notifications.dismissAllNotificationsAsync(); | ||
| if (isCancelled()) return "replaced by a newer request"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| if (Platform.OS === "android") { | ||
| return ( | ||
|
|
@@ -27,7 +30,12 @@ export async function stageShowcaseAgentActivity( | |
| } | ||
| if (Platform.OS !== "ios") return `unsupported platform ${Platform.OS}`; | ||
|
|
||
| // A stale fixture is staged with a staleDate that passes almost at once, as a silent relay would leave it. | ||
| const staleDate = activity.activities.some((row) => row.phase === "stale") | ||
| ? new Date(now + 2_000) | ||
| : undefined; | ||
| await Promise.all(getAgentLiveActivities().map((existing) => existing.end("immediate"))); | ||
| if (isCancelled()) return "replaced by a newer request"; | ||
| // ActivityKit needs foreground startup; the runner locks only after this scene is ready. | ||
| return startAgentLiveActivity(activity) !== null || "Live Activity did not start"; | ||
| return startAgentLiveActivity(activity, staleDate) !== null || "Live Activity did not start"; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.