Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 64 additions & 41 deletions apps/mobile/src/features/tasks/components/TaskSessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ interface TaskSessionViewProps {
pendingPermissions?: Record<string, CloudPendingPermissionRequest>;
isConnecting?: boolean;
isThinking?: boolean;
terminalStatus?: "failed" | "completed";
terminalStatus?: "failed" | "completed" | "cancelled";
lastError?: string | null;
onRetry?: () => void;
onOpenTask?: (taskId: string) => void;
Expand Down Expand Up @@ -795,6 +795,64 @@ function ConnectingIndicator() {
);
}

// Terminal-state banner shown at the top of a finished run's transcript.
// "failed" reads as an error and offers a retry; "completed" and "cancelled"
// are non-error ends (the latter meaning the run was stopped / the user
// finished with it) and offer to continue the conversation.
function TerminalStatusBanner({
terminalStatus,
lastError,
onRetry,
}: {
terminalStatus: "failed" | "completed" | "cancelled";
lastError?: string | null;
onRetry?: () => void;
}) {
const isFailed = terminalStatus === "failed";
const label =
terminalStatus === "failed"
? "Run failed"
: terminalStatus === "cancelled"
? "Run stopped"
: "Run completed";
const actionLabel = isFailed ? "Retry" : "Continue";

return (
<View
className={`mx-4 mt-2 mb-4 rounded-lg px-4 py-3 ${
isFailed ? "bg-status-error/10" : "bg-status-success/10"
}`}
>
<Text
className={`font-semibold text-sm ${
isFailed ? "text-status-error" : "text-status-success"
}`}
>
{label}
</Text>
{lastError && (
<Text className="mt-1 text-gray-11 text-xs">{lastError}</Text>
)}
{onRetry && (
<Pressable
onPress={onRetry}
className={`mt-2 self-start rounded-md px-3 py-1.5 ${
isFailed ? "bg-status-error/20" : "bg-status-success/20"
}`}
>
<Text
className={`font-medium text-xs ${
isFailed ? "text-status-error" : "text-status-success"
}`}
>
{actionLabel}
</Text>
</Pressable>
)}
</View>
);
}

export function TaskSessionView({
events,
pendingPermissions,
Expand Down Expand Up @@ -1017,46 +1075,11 @@ export function TaskSessionView({
initialNumToRender={30}
ListHeaderComponent={
terminalStatus ? (
<View
className={`mx-4 mt-2 mb-4 rounded-lg px-4 py-3 ${
terminalStatus === "failed"
? "bg-status-error/10"
: "bg-status-success/10"
}`}
>
<Text
className={`font-semibold text-sm ${
terminalStatus === "failed"
? "text-status-error"
: "text-status-success"
}`}
>
{terminalStatus === "failed" ? "Run failed" : "Run completed"}
</Text>
{lastError && (
<Text className="mt-1 text-gray-11 text-xs">{lastError}</Text>
)}
{onRetry && (
<Pressable
onPress={onRetry}
className={`mt-2 self-start rounded-md px-3 py-1.5 ${
terminalStatus === "failed"
? "bg-status-error/20"
: "bg-status-success/20"
}`}
>
<Text
className={`font-medium text-xs ${
terminalStatus === "failed"
? "text-status-error"
: "text-status-success"
}`}
>
{terminalStatus === "failed" ? "Retry" : "Continue"}
</Text>
</Pressable>
)}
</View>
<TerminalStatusBanner
terminalStatus={terminalStatus}
lastError={lastError}
onRetry={onRetry}
/>
) : null
}
/>
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/tasks/lib/cloudTaskStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ function emitSnapshot(watcher: WatcherState, entries: StoredLogEntry[]): void {
output: watcher.lastOutput,
errorMessage: watcher.lastErrorMessage,
branch: watcher.lastBranch,
statusUpdatedAt: watcher.lastStatusUpdatedAt,
});
}

Expand All @@ -579,6 +580,7 @@ function emitStatus(watcher: WatcherState): void {
output: watcher.lastOutput,
errorMessage: watcher.lastErrorMessage,
branch: watcher.lastBranch,
statusUpdatedAt: watcher.lastStatusUpdatedAt,
});
}

Expand Down
164 changes: 164 additions & 0 deletions apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

// --- Mocks for side-effecting / native deps pulled in by the store ---------

const presentLocalNotification = vi.fn().mockResolvedValue(undefined);
vi.mock("@/features/notifications/lib/notifications", () => ({
presentLocalNotification: (...args: unknown[]) =>
presentLocalNotification(...args),
}));

const playMeepSound = vi.fn().mockResolvedValue(undefined);
vi.mock("../utils/sounds", () => ({
playMeepSound: () => playMeepSound(),
}));

vi.mock("expo-haptics", () => ({
notificationAsync: vi.fn(),
NotificationFeedbackType: { Success: "success" },
}));

vi.mock("@/features/preferences/stores/preferencesStore", () => ({
usePreferencesStore: {
getState: () => ({ pingsEnabled: true, pushNotificationsEnabled: true }),
},
}));

// Network/cloud APIs — never hit in these tests but imported by the module.
vi.mock("../api", () => ({
CloudCommandError: class CloudCommandError extends Error {},
getTask: vi.fn(),
runTaskInCloud: vi.fn(),
sendCloudCommand: vi.fn(),
}));

vi.mock("../lib/cloudTaskStream", () => ({
watchCloudTask: vi.fn(),
}));

// Pulls in expo-file-system → expo-modules-core, which needs the RN-injected
// __DEV__ global; stub the module so the store loads under the node env.
vi.mock("../composer/attachments/buildCloudPrompt", () => ({
buildCloudPromptBlocks: vi.fn(),
}));

import type { TaskSession } from "./taskSessionStore";
import { useTaskSessionStore } from "./taskSessionStore";

// Unique ids per test — maybePresentLocalNotification keeps a module-level
// per-task dedup window that would otherwise suppress later tests' pings.
let idCounter = 0;
let TASK_RUN_ID = "run-0";
let TASK_ID = "task-0";

function seedSession(overrides: Partial<TaskSession> = {}): void {
const session: TaskSession = {
taskRunId: TASK_RUN_ID,
taskId: TASK_ID,
taskTitle: "My task",
events: [],
status: "connected",
isPromptPending: true,
awaitingPing: true,
...overrides,
};
useTaskSessionStore.setState({
sessions: { [TASK_RUN_ID]: session },
// Not focused on this task, so the OS-banner suppression doesn't kick in.
focusedTaskId: null,
});
}

const recentTimestamp = () => new Date(Date.now() - 1000).toISOString();
const staleTimestamp = () =>
new Date(Date.now() - 10 * 60 * 1000).toISOString();

describe("taskSessionStore terminal notifications", () => {
beforeEach(() => {
presentLocalNotification.mockClear();
playMeepSound.mockClear();
idCounter += 1;
TASK_RUN_ID = `run-${idCounter}`;
TASK_ID = `task-${idCounter}`;
useTaskSessionStore.setState({ sessions: {}, focusedTaskId: null });
});

it("does NOT fire a failure notification for a cancelled run", () => {
seedSession();
useTaskSessionStore.getState()._handleCloudUpdate(TASK_RUN_ID, {
taskId: TASK_ID,
runId: TASK_RUN_ID,
kind: "status",
status: "cancelled",
statusUpdatedAt: recentTimestamp(),
});

expect(presentLocalNotification).not.toHaveBeenCalled();
// The run is still recorded as a distinct, non-failed terminal state.
expect(
useTaskSessionStore.getState().sessions[TASK_RUN_ID]?.terminalStatus,
).toBe("cancelled");
});

it("fires a failure notification for a recent failed run", () => {
seedSession();
useTaskSessionStore.getState()._handleCloudUpdate(TASK_RUN_ID, {
taskId: TASK_ID,
runId: TASK_RUN_ID,
kind: "status",
status: "failed",
errorMessage: "boom",
statusUpdatedAt: recentTimestamp(),
});

expect(presentLocalNotification).toHaveBeenCalledTimes(1);
expect(presentLocalNotification.mock.calls[0][0].body).toContain("failed");
});

it("does NOT fire for a failed run that terminated long ago (stale reconnect)", () => {
seedSession();
useTaskSessionStore.getState()._handleCloudUpdate(TASK_RUN_ID, {
taskId: TASK_ID,
runId: TASK_RUN_ID,
kind: "snapshot",
newEntries: [],
totalEntryCount: 0,
status: "failed",
statusUpdatedAt: staleTimestamp(),
});

expect(presentLocalNotification).not.toHaveBeenCalled();
expect(
useTaskSessionStore.getState().sessions[TASK_RUN_ID]?.terminalStatus,
).toBe("failed");
});

it("does NOT fire when the user was not awaiting a ping", () => {
seedSession({ awaitingPing: false });
useTaskSessionStore.getState()._handleCloudUpdate(TASK_RUN_ID, {
taskId: TASK_ID,
runId: TASK_RUN_ID,
kind: "status",
status: "failed",
statusUpdatedAt: recentTimestamp(),
});

expect(presentLocalNotification).not.toHaveBeenCalled();
});

it("fires a completion notification for a recent completed run", () => {
seedSession();
useTaskSessionStore.getState()._handleCloudUpdate(TASK_RUN_ID, {
taskId: TASK_ID,
runId: TASK_RUN_ID,
kind: "status",
status: "completed",
statusUpdatedAt: recentTimestamp(),
});

expect(presentLocalNotification).toHaveBeenCalledTimes(1);
expect(presentLocalNotification.mock.calls[0][0].body).toContain(
"finished",
);
});
});
52 changes: 45 additions & 7 deletions apps/mobile/src/features/tasks/stores/taskSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,8 @@ export interface TaskSession {
// the log). Used to dedup the canonical copy against the echo.
localUserEchoes?: Set<string>;
// Terminal backend status for this run, populated by status updates so the
// UI can surface "Run failed" / "Run completed".
terminalStatus?: "failed" | "completed";
// UI can surface "Run failed" / "Run completed" / "Run stopped".
terminalStatus?: "failed" | "completed" | "cancelled";
lastError?: string | null;
// True when the user initiated work (new task, sendPrompt, resume) and
// we should play a sound when control returns. False when reconnecting
Expand Down Expand Up @@ -331,12 +331,32 @@ const connectAttempts = new Set<string>();

function mapTerminalStatus(
status: string | undefined | null,
): "completed" | "failed" | undefined {
): "completed" | "failed" | "cancelled" | undefined {
if (status === "completed") return "completed";
if (status === "failed" || status === "cancelled") return "failed";
if (status === "failed") return "failed";
// A "cancelled" run is a normal lifecycle end — the sandbox was stopped or
// the user finished with the task. It is NOT a failure, so it is kept
// distinct here instead of being collapsed into "failed".
if (status === "cancelled") return "cancelled";
return undefined;
}

// A terminal transition is "stale" when the backend recorded it well before
// we observed it — i.e. the run ended while this device wasn't watching and
// we're only now catching up via a reconnect snapshot. Firing a (possibly
// hours-late) "failed"/"finished" ping for a run the user has already moved on
// from is the spurious notification we want to avoid. A missing/unparseable
// timestamp is treated as fresh so a genuine, just-happened terminal alert is
// never suppressed.
const TERMINAL_PING_STALENESS_MS = 2 * 60 * 1000;

function isTerminalTransitionStale(statusUpdatedAt?: string | null): boolean {
if (!statusUpdatedAt) return false;
const ts = Date.parse(statusUpdatedAt);
if (Number.isNaN(ts)) return false;
return Date.now() - ts > TERMINAL_PING_STALENESS_MS;
}

export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
sessions: {},
focusedTaskId: null,
Expand Down Expand Up @@ -964,7 +984,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
if (update.kind === "status" || update.kind === "snapshot") {
if (isTerminalStatus(update.status)) {
const preState = get().sessions[taskRunId];
const shouldPing = preState?.awaitingPing ?? false;
const wasAwaitingPing = preState?.awaitingPing ?? false;
const terminal = mapTerminalStatus(update.status);
set((state) => {
const current = state.sessions[taskRunId];
Expand All @@ -982,14 +1002,32 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
},
};
});

// Only alert when (a) the user was actively awaiting a ping on this
// device, (b) the run reached this terminal state recently — not a
// stale reconnect to a run that ended while we were away — and (c) the
// run actually failed or completed. A "cancelled" run is a normal
// lifecycle end (sandbox stopped / user finished), so it never pings;
// surfacing it as "<task> failed" was the source of spurious failure
// notifications for idle/finished tasks.
const notifyKind: "task_failed" | "turn_complete" | null =
terminal === "failed"
? "task_failed"
: terminal === "completed"
? "turn_complete"
: null;
const shouldPing =
wasAwaitingPing &&
notifyKind !== null &&
!isTerminalTransitionStale(update.statusUpdatedAt);
if (shouldPing && usePreferencesStore.getState().pingsEnabled) {
playMeepSound().catch(() => {});
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
if (shouldPing) {
if (shouldPing && notifyKind) {
maybePresentLocalNotification({
taskRunId,
kind: terminal === "failed" ? "task_failed" : "turn_complete",
kind: notifyKind,
});
}
}
Expand Down
Loading
Loading