Skip to content
Merged
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
40 changes: 35 additions & 5 deletions .agents/skills/test-t3-app/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: test-t3-app
description: Launch and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, test UI behavior in a browser, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite.
description: Launch, retain, and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, cross-turn dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, iteratively test UI behavior with a human, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite.
---

# Test T3 App
Expand All @@ -13,13 +13,35 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi
2. Choose a base directory that belongs only to the current worktree or test:
- Use the repository's ignored `.t3` directory for reusable worktree-local state.
- Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path.
3. Start the full web stack with `vp run dev --home-dir <base-dir>`.
3. Start the full web stack with `vp run dev`. Add `--share` when the user needs to open it from another tailnet device. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir <base-dir>` only when the test needs a different isolated directory.
4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.

Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership.

The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server.

Ports are derived from the worktree path but can shift when occupied. Always read the actual values from the `[dev-runner]` line.

Shared browser dev is single-origin: Vite proxies the backend paths, so never set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`.

The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it.

### Verify a shared environment before human handoff

When another person will use the printed pairing URL, first open the shared origin without the pairing path or fragment in the controlled browser and confirm the T3 Code app loads. This browser navigation is required even when curl succeeds because browsers block some otherwise reachable ports before making a network request.

Do not open the other person's complete pairing URL during this reachability check; doing so consumes its one-time token. If the agent also needs an authenticated browser, create and consume a separate pairing token, then leave a fresh token for the other person.

## Preserve the environment while iterating

Treat the overall testing or implementation loop—not an assistant turn or one verification pass—as the environment lifecycle boundary.

- Keep the dev process, base directory, selected ports, authenticated browser tab, registered projects, and seeded fixtures alive while the user may inspect the result or request follow-up changes.
- Do not stop the server merely because one verification pass completed or because you are yielding a response to the user.
- Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state.
- On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid.
- Tell the user when a test environment remains available, including its non-secret web URL when useful. Never include a pairing token.

## Authenticate the browser on the first navigation

1. Wait for the server log that says authentication is required and includes a URL ending in `/pair#token=...`.
Expand All @@ -45,7 +67,7 @@ T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \

Use the `Pair URL` from this command once. Derive `<server-port>` and `<web-url>` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port.

Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `<base-dir>/userdata`; the `<base-dir>/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets.
Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `<base-dir>/userdata`; the `<base-dir>/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `<worktree>/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets.

## Inspect or seed SQLite state

Expand All @@ -58,9 +80,17 @@ Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before chang

The helper refuses to write to the shared `~/.t3` directory by default and creates a database backup before each mutation.

## Finish the test
## Tear down only when the testing loop is finished

Tear down when the user explicitly asks, confirms the iteration is finished, or the overall task is genuinely complete with no pending human review. Do not infer completion from the end of an assistant turn.

When teardown is appropriate:

1. Stop the dev process with its terminal interrupt.
2. Preserve the isolated base directory when it contains useful reproduction evidence or state for a likely follow-up.
3. Otherwise remove only a path created for this test after resolving and verifying the exact target.

Stop the dev process with its terminal interrupt. Preserve the isolated base directory when it contains useful reproduction evidence; otherwise remove only a path that was created for this test after resolving and verifying the exact target. A fresh isolated base directory is the safest reset when authentication, migrations, or fixture state becomes ambiguous.
If completion is uncertain, keep the environment alive and mention that it is retained for further iteration. A fresh isolated base directory remains the safest reset when authentication, migrations, or fixture state becomes ambiguous.

## Troubleshoot predictably

Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/test-t3-app/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
interface:
display_name: "T3 App Testing"
short_description: "Launch and seed isolated T3 test environments"
default_prompt: "Use $test-t3-app to launch an isolated T3 development environment and test it in the browser."
short_description: "Launch and retain isolated T3 test environments"
default_prompt: "Use $test-t3-app to launch an isolated T3 environment and iteratively test it in the browser while preserving state."
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@
- When preparing fork PRs, branch from `origin/main` and target `tarik02/t3code:main`.
- If a fork PR branch accidentally includes upstream history, rebuild it from `origin/main` and replay only the intended diff.

## Dev Servers

- In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins.
- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet.
- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`.
- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them.
- Before handing off a `--share` URL, open its origin in a controlled browser and confirm the app loads. A successful curl is insufficient because browsers reject some otherwise reachable ports.

## Package Roles

- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export function ThreadWorkLog(props: {
<View className="gap-px">
{rows.map((row) => {
const expanded = props.expandedRows[row.id] ?? false;
const canExpand = row.fullDetail !== null;
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";

Expand All @@ -133,7 +134,7 @@ export function ThreadWorkLog(props: {
props.onToggleRow(row.id);
}
}}
onLongPress={() => props.onCopyRow(row.id, row.copyText)}
onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}
style={({ pressed }) => ({
backgroundColor: pressed ? pressedBackground : "transparent",
})}
Expand Down Expand Up @@ -204,7 +205,7 @@ export function ThreadWorkLog(props: {
</View>
</Pressable>

{expanded && row.fullDetail ? (
{fullDetail ? (
<View className="ml-7 border-l border-neutral-300/60 pb-1 pl-3 pt-0.5 dark:border-white/[0.12]">
<ScrollView
nestedScrollEnabled
Expand All @@ -217,7 +218,7 @@ export function ThreadWorkLog(props: {
selectable
className="font-mono text-2xs leading-normal text-foreground-muted"
>
{row.fullDetail}
{fullDetail}
</Text>
</ScrollView>
</View>
Expand Down
86 changes: 68 additions & 18 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,20 +162,22 @@ describe("buildThreadFeed", () => {
return;
}

expect(group.activities).toEqual([
{
id: "tool-completed",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: "turn-1",
summary: "Run tests",
detail: "bun run test",
fullDetail: "/bin/zsh -lc 'bun run test'",
copyText: "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",
icon: "command",
toolLike: true,
status: "success",
},
]);
expect(group.activities).toHaveLength(1);
expect(group.activities[0]).toMatchObject({
id: "tool-completed",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: "turn-1",
summary: "Run tests",
detail: "bun run test",
canExpand: true,
icon: "command",
toolLike: true,
status: "success",
});
expect(group.activities[0]?.getFullDetail()).toBe("/bin/zsh -lc 'bun run test'");
expect(group.activities[0]?.getCopyText()).toBe(
"Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",
);
});

it("keeps MCP inputs available to expanded mobile work rows", () => {
Expand Down Expand Up @@ -224,8 +226,55 @@ describe("buildThreadFeed", () => {
}

expect(group.activities[0]?.icon).toBe("wrench");
expect(group.activities[0]?.fullDetail).toContain('"query": "work log"');
expect(group.activities[0]?.fullDetail).toContain("repository.search");
expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"');
expect(group.activities[0]?.getFullDetail()).toContain("repository.search");
});

it("defers large tool output expansion until a work row is opened or copied", () => {
let serializedToolOutputs = 0;
const activities = Array.from({ length: 5_000 }, (_, index) =>
makeActivity({
id: EventId.make(`large-tool-${index}`),
kind: "tool.completed",
tone: "tool",
summary: `Tool ${index}`,
createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(),
payload: {
title: `Tool ${index}`,
itemType: "mcp_tool_call",
status: "completed",
data: {
item: {
toJSON: () => {
serializedToolOutputs += 1;
return { output: "x".repeat(32_768) };
},
},
},
},
}),
);
const thread = makeThread({
id: ThreadId.make("thread-large-tools"),
projectId: ProjectId.make("project-1"),
title: "Large tools",
activities,
});

const feed = buildThreadFeed(thread);
expect(serializedToolOutputs).toBe(0);

const group = feed[0];
expect(group).toMatchObject({ type: "activity-group" });
if (!group || group.type !== "activity-group") {
return;
}

expect(group.activities).toHaveLength(5_000);
expect(group.activities[0]?.getFullDetail()).toContain('"output"');
expect(serializedToolOutputs).toBe(1);
expect(group.activities[0]?.getCopyText()).toContain('"output"');
expect(serializedToolOutputs).toBe(1);
});

it("folds settled turn work while leaving the terminal answer visible", () => {
Expand Down Expand Up @@ -440,8 +489,9 @@ describe("buildThreadFeed", () => {
turnId: null,
summary: `Tool ${id}`,
detail: null,
fullDetail: null,
copyText: id,
canExpand: false,
getFullDetail: () => null,
getCopyText: () => id,
icon: "command",
toolLike: true,
status,
Expand Down
44 changes: 35 additions & 9 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ export interface ThreadFeedActivity {
readonly turnId: TurnId | null;
readonly summary: string;
readonly detail: string | null;
readonly fullDetail: string | null;
readonly copyText: string;
readonly canExpand: boolean;
readonly getFullDetail: () => string | null;
readonly getCopyText: () => string;
readonly icon:
| "agent"
| "alert"
Expand Down Expand Up @@ -554,6 +555,27 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
return blocks.length > 0 ? blocks.join("\n\n") : null;
}

function workEntryHasExpandedBody(entry: WorkLogEntry): boolean {
return (
(entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) ||
Boolean((entry.rawCommand ?? entry.command)?.trim()) ||
Boolean(entry.detail?.trim()) ||
(entry.changedFiles?.some((path) => path.trim().length > 0) ?? false)
);
}

function memoizeValue<T>(build: () => T): () => T {
let value: T;
let initialized = false;
return () => {
if (!initialized) {
value = build();
initialized = true;
}
return value;
};
}

function workEntryPreview(
workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,
): string | null {
Expand Down Expand Up @@ -1353,7 +1375,14 @@ export function buildThreadFeed(
.map<RawThreadFeedEntry>((entry) => {
const summary = workEntryHeading(entry);
const detail = workEntryPreview(entry);
const fullDetail = buildWorkEntryExpandedBody(entry);
const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry));
const getCopyText = memoizeValue(() =>
[summary, detail, getFullDetail()]
.filter((value, index, values): value is string => {
return Boolean(value) && values.indexOf(value) === index;
})
.join("\n"),
);
return {
type: "activity",
id: entry.id,
Expand All @@ -1365,13 +1394,10 @@ export function buildThreadFeed(
turnId: entry.turnId,
summary,
detail,
fullDetail,
canExpand: workEntryHasExpandedBody(entry),
getFullDetail,
getCopyText,
icon: workEntryIcon(entry),
copyText: [summary, detail, fullDetail]
.filter((value, index, values): value is string => {
return Boolean(value) && values.indexOf(value) === index;
})
.join("\n"),
toolLike: workLogEntryIsToolLike(entry),
status: workEntryStatus(entry),
},
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import * as ServerConfig from "../config.ts";
import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
import * as PairingGrantStore from "./PairingGrantStore.ts";
import * as EnvironmentAuth from "./EnvironmentAuth.ts";
import { resolveSessionCookieName } from "./utils.ts";

import * as ServerSecretStore from "./ServerSecretStore.ts";

/** Pinned so dev-mode cookie tests can assert the port-scoped name. */
const TEST_SERVER_PORT = 13_773;

const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
Layer.effect(
ServerConfig.ServerConfig,
Expand All @@ -19,6 +23,12 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se
return {
...config,
...overrides,
// Last, so the port cannot be overridden out from under
// makeCookieRequest — which builds the cookie name from this constant.
// An override that changed it would leave the server reading
// t3_session_<other> while every request still sent t3_session_13773,
// and the tests would fail for a reason unrelated to what they assert.
port: TEST_SERVER_PORT,
} satisfies ServerConfig.ServerConfig["Service"];
}),
).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
Expand All @@ -35,7 +45,11 @@ const makeCookieRequest = (
): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] =>
({
cookies: {
t3_session: sessionToken,
// Derived, not hardcoded: the name is port-scoped so concurrent servers
// on one hostname don't share a cookie. Mode and devUrl mirror
// ServerConfig.layerTest, so this resolves to whatever the server reads.
[resolveSessionCookieName({ mode: "web", port: TEST_SERVER_PORT, devUrl: undefined })]:
sessionToken,
},
headers: {},
}) as unknown as Parameters<
Expand Down
Loading
Loading