Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ node_modules/
*.log
.env*
!.env.example
.superpowers/
73 changes: 73 additions & 0 deletions .t3/boards/delivery.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"name": "Standard delivery",
"settings": {
"maxConcurrentTickets": 3
},
"lanes": [
{
"key": "backlog",
"name": "Backlog",
"entry": "manual"
},
{
"key": "implement",
"name": "Implement",
"entry": "auto",
"pipeline": [
{
"key": "code",
"type": "agent",
"agent": {
"instance": "codex",
"model": "gpt-5.5",
"options": [
{
"id": "reasoningEffort",
"value": "xhigh"
}
]
},
"instruction": "Implement the requested ticket in this worktree. Keep the change focused, run the relevant checks, and report the verification evidence.",
"captureOutput": true
},
{
"key": "review",
"type": "agent",
"agent": {
"instance": "codex",
"model": "gpt-5.5",
"options": [
{
"id": "reasoningEffort",
"value": "medium"
}
]
},
"instruction": "Review the accumulated diff for blocking correctness, reliability, or integration issues. List only issues that must be fixed before the ticket can ship.",
"captureOutput": true
}
],
"on": {
"success": "owner_review",
"failure": "needs_attention",
"blocked": "needs_attention"
}
},
{
"key": "owner_review",
"name": "Owner Review",
"entry": "manual"
},
{
"key": "needs_attention",
"name": "Needs Attention",
"entry": "manual"
},
{
"key": "done",
"name": "Done",
"entry": "manual",
"terminal": true
}
]
}
10 changes: 10 additions & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ function AppNavigator() {
headerShown: false,
}}
/>
<Stack.Screen
name="tickets/[environmentId]/[boardId]/[ticketId]"
options={{
animation: "slide_from_right",
contentStyle: { backgroundColor: "transparent" },
gestureEnabled: true,
headerShown: false,
}}
/>
<Stack.Screen name="needs-you" options={settingsSheetScreenOptions} />
</Stack>
</>
);
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/app/needs-you.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { NeedsYouInboxScreen } from "../features/board/NeedsYouInboxScreen";

export default function NeedsYouRoute() {
return <NeedsYouInboxScreen />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { TicketActionSheetScreen } from "../../../../../features/board/TicketActionSheetScreen";

export default function TicketRoute() {
return <TicketActionSheetScreen />;
}
290 changes: 290 additions & 0 deletions apps/mobile/src/features/agent-awareness/notificationPayload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import { describe, expect, it } from "vite-plus/test";

import {
encodeTicketDeepLink,
extractAgentNotificationDeepLink,
normalizeTicketDeepLink,
routeAgentNotificationResponseOnce,
} from "./notificationPayload";

function responseWithData(data: Record<string, unknown>, identifier = "notification-1") {
return {
notification: {
request: {
identifier,
content: {
data,
},
},
},
};
}

// ---------------------------------------------------------------------------
// encodeTicketDeepLink
// ---------------------------------------------------------------------------
describe("encodeTicketDeepLink", () => {
it("returns null when environmentId is empty", () => {
expect(encodeTicketDeepLink({ environmentId: "", boardId: "b1", ticketId: "t1" })).toBeNull();
});

it("returns null when boardId is empty", () => {
expect(
encodeTicketDeepLink({ environmentId: "env", boardId: "", ticketId: "t1" }),
).toBeNull();
});

it("returns null when ticketId is empty", () => {
expect(
encodeTicketDeepLink({ environmentId: "env", boardId: "b1", ticketId: "" }),
).toBeNull();
});

it("encodes a basic ticket deep link", () => {
expect(
encodeTicketDeepLink({ environmentId: "env-1", boardId: "board-1", ticketId: "ticket-1" }),
).toBe("/tickets/env-1/board-1/ticket-1");
});

it("percent-encodes components with special characters", () => {
expect(
encodeTicketDeepLink({
environmentId: "env 1",
boardId: "board/2",
ticketId: "ticket 3",
}),
).toBe("/tickets/env%201/board%2F2/ticket%203");
});
});

// ---------------------------------------------------------------------------
// normalizeTicketDeepLink
// ---------------------------------------------------------------------------
describe("normalizeTicketDeepLink", () => {
it("accepts and round-trips a well-formed ticket path", () => {
expect(normalizeTicketDeepLink("/tickets/env-1/b1/t1")).toBe("/tickets/env-1/b1/t1");
});

it("accepts a path with percent-encoded components", () => {
expect(normalizeTicketDeepLink("/tickets/env%201/board%2F2/ticket%203")).toBe(
"/tickets/env%201/board%2F2/ticket%203",
);
});

it("rejects a path with too few segments (missing ticketId)", () => {
expect(normalizeTicketDeepLink("/tickets/env-1/b1")).toBeNull();
});

it("rejects a path with too many segments", () => {
expect(normalizeTicketDeepLink("/tickets/a/b/c/d")).toBeNull();
});

it("rejects a thread path", () => {
expect(normalizeTicketDeepLink("/threads/env-1/t1")).toBeNull();
});

it("rejects a path with a query string", () => {
expect(normalizeTicketDeepLink("/tickets/env/b/t?x=1")).toBeNull();
});

it("rejects a path with a hash fragment", () => {
expect(normalizeTicketDeepLink("/tickets/env/b/t#section")).toBeNull();
});

it("rejects a path with leading double-slash", () => {
expect(normalizeTicketDeepLink("//tickets/env/b/t")).toBeNull();
});

it("rejects a value with surrounding whitespace", () => {
expect(normalizeTicketDeepLink(" /tickets/env/b/t")).toBeNull();
expect(normalizeTicketDeepLink("/tickets/env/b/t ")).toBeNull();
});

it("rejects an empty middle segment (passes 5-segment check, fails encode)", () => {
expect(normalizeTicketDeepLink("/tickets/env//t")).toBeNull();
});
});

// ---------------------------------------------------------------------------
// extractAgentNotificationDeepLink — ticket paths
// ---------------------------------------------------------------------------
describe("extractAgentNotificationDeepLink — ticket deep links", () => {
it("uses explicit ticket deep link from APNs payload data", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
deepLink: "/tickets/env/b/t",
}),
),
).toBe("/tickets/env/b/t");
});

it("normalizes explicit ticket deep links with encoded components", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
deepLink: "/tickets/env%201/board%2F2/ticket%203",
}),
),
).toBe("/tickets/env%201/board%2F2/ticket%203");
});

it("falls back to identity fields when no deepLink", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
environmentId: "env 1",
boardId: "board/2",
ticketId: "ticket 3",
}),
),
).toBe("/tickets/env%201/board%2F2/ticket%203");
});

it("uses ticket identity fallback when deepLink is not a recognized route", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
deepLink: "/",
environmentId: "env",
boardId: "b",
ticketId: "t",
}),
),
).toBe("/tickets/env/b/t");
});

it("ignores malformed ticket deep link and falls back to ids", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
deepLink: "/tickets/env/b",
environmentId: "env",
boardId: "b",
ticketId: "t",
}),
),
).toBe("/tickets/env/b/t");
});
});

// ---------------------------------------------------------------------------
// REGRESSION: thread paths still work
// ---------------------------------------------------------------------------
describe("extractAgentNotificationDeepLink — thread deep links (regression)", () => {
it("uses explicit thread deep link from APNs payload data", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
deepLink: "/threads/env/thread",
environmentId: "ignored",
threadId: "ignored",
}),
),
).toBe("/threads/env/thread");
});

it("prefers the thread identity fallback over ticket when both id sets are present", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
environmentId: "env",
threadId: "thread",
boardId: "b",
ticketId: "t",
}),
),
).toBe("/threads/env/thread");
});

it("normalizes explicit thread deep links from APNs payload data", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
deepLink: "/threads/env%201/thread%2F2",
}),
),
).toBe("/threads/env%201/thread%2F2");
});

it("falls back to the thread route from environment and thread ids", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
environmentId: "env 1",
threadId: "thread/2",
}),
),
).toBe("/threads/env%201/thread%2F2");
});

it("falls back to thread ids when explicit deep link is not a recognized route", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
deepLink: "/",
environmentId: "env",
threadId: "thread",
}),
),
).toBe("/threads/env/thread");
});

it("ignores malformed or external links with no usable fallback", () => {
expect(
extractAgentNotificationDeepLink(responseWithData({ deepLink: "https://example.com" })),
).toBeNull();
expect(
extractAgentNotificationDeepLink(responseWithData({ deepLink: "/settings" })),
).toBeNull();
expect(
extractAgentNotificationDeepLink(responseWithData({ deepLink: "//example.com" })),
).toBeNull();
expect(
extractAgentNotificationDeepLink(responseWithData({ deepLink: "/threads/env/thread?x=1" })),
).toBeNull();
expect(extractAgentNotificationDeepLink({})).toBeNull();
});

it("falls back to ticket identity when threadId is an empty string", () => {
// An empty threadId must NOT short-circuit into the thread branch and return
// null; the ticket-identity fallback must run instead.
expect(
extractAgentNotificationDeepLink(
responseWithData({
environmentId: "env",
threadId: "",
boardId: "board-1",
ticketId: "ticket-1",
}),
),
).toBe("/tickets/env/board-1/ticket-1");
});
});

// ---------------------------------------------------------------------------
// routeAgentNotificationResponseOnce (regression)
// ---------------------------------------------------------------------------
describe("routeAgentNotificationResponseOnce", () => {
it("does not navigate twice when the initial and listener responses refer to one notification", () => {
const handledResponseIds = new Set<string>();
const navigations: Array<string> = [];
const response = responseWithData({
environmentId: "env",
threadId: "thread",
});

routeAgentNotificationResponseOnce({
handledResponseIds,
response,
navigate: (deepLink) => navigations.push(deepLink),
});
routeAgentNotificationResponseOnce({
handledResponseIds,
response,
navigate: (deepLink) => navigations.push(deepLink),
});

expect(navigations).toEqual(["/threads/env/thread"]);
});
});
Loading
Loading