Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ The script auto-derives a unique `T3CODE_HOME` per worktree/path, so multiple wo
- To run a single test file, run `bun run test <path-to-test-file>` from the owning package directory such as `apps/web`, `apps/server`, `packages/contracts`, or `packages/shared`.
- Run web test files from `apps/web` so the app-local Vitest config and path aliases such as `~/*` are applied.

### Test suites

| Suite | Package | Command | Coverage |
| ----------------------- | ------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unit tests | `apps/server` | `bun run test` (from `apps/server`) | 147 test files — persistence, auth, orchestration deciders, provider registry, WS RPC scopes |
| Unit tests | `apps/web` | `bun run test` (from `apps/web`) | 105 test files — component logic, hooks, store, sidebar, chat composer, local API |
| Unit tests | `packages/contracts` | `bun run test` (from `packages/contracts`) | 12 test files — schema round-trips, RPC contract types |
| Unit tests | `packages/shared` | `bun run test` (from `packages/shared`) | 29 test files — todo store, keybindings, git utilities |
| Unit tests | `packages/client-runtime` | `bun run test` (from `packages/client-runtime`) | 24 test files — WS RPC protocol, thread detail reducer, remote API |
| WS RPC scope regression | `apps/server` | `bun run test src/wsRpcScopes.test.ts` | Asserts every `WsRpcGroup` method has a declared authorization scope in `RPC_REQUIRED_SCOPE`. Missing entries cause connection-level protocol defects. |
| E2E smoke | `apps/server` | `node scratchpad/e2e-smoke.ts` | Boots the real server in-process (temp `baseDir`, desktop bootstrap token), authenticates, opens a real `/ws` connection, and exercises `getConfig`, `todo.load`, `subscribeServerConfig`, `project.create`, `thread.create`, `thread.turn.start`, and `subscribeShell`. Exit 0 = pass. |
| Attach smoke | `apps/server` | `node scratchpad/attach-smoke.ts <serverUrl> <pairingToken>` | Same RPC checks as e2e-smoke against an already-running server. |
| Browser probe | `apps/web` | `node scratchpad/e2e-browser-probe.ts "<pairingUrl>"` | Playwright headless probe of the real dev pairing flow. Navigates to `/settings/providers`, asserts no stuck "Checking provider status" text, and dumps console/network/WS-frame diagnostics. Requires `playwright` installed. |

E2E smoke harnesses exercise the full WebSocket RPC stack end-to-end. They catch contract-implementation drift that unit tests miss (e.g. a method absent from `RPC_REQUIRED_SCOPE` tears down the entire WebSocket, but unit tests of individual deciders or services pass). Consider adding a smoke gate to CI so "merged without booting" cannot recur.

Mint pairing tokens for the dev DB with:

```bash
node apps/server/src/bin.ts auth pairing create --base-dir <T3CODE_HOME> --dev-url http://localhost:<webPort>
```

Note: `--dev-url` selects the `dev/` state subdirectory; omit it to write to `userdata/`. The CLI and dev server must agree on the subdirectory or tokens will be silently invalid.

## Project Snapshot

T3 Code is a minimal web GUI for using coding agents like Codex and Claude.
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function makeThread(
proposedPlans: [],
activities: [],
checkpoints: [],
contextTrimPoints: [],
session: null,
...input,
};
Expand Down
5 changes: 5 additions & 0 deletions apps/server/integration/TestProviderAdapter.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,10 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
sessions.clear();
});

const compactThread: ProviderAdapterShape<ProviderAdapterError>["compactThread"] = (
_threadId,
) => Effect.succeed({ summary: "compacted", durationMs: 0 });

const adapter: ProviderAdapterShape<ProviderAdapterError> = {
provider,
capabilities: {
Expand All @@ -504,6 +508,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
hasSession,
readThread,
rollbackThread,
compactThread,
stopAll,
streamEvents: Stream.fromQueue(runtimeEvents),
};
Expand Down
210 changes: 210 additions & 0 deletions apps/server/scratchpad/attach-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Attach-mode smoke: exercises an ALREADY-RUNNING server over the network.
* Auth via a pairing token, then WS RPC round-trips.
*
* Usage: node scratchpad/attach-smoke.ts <httpBaseUrl> <pairingToken>
* e.g. node scratchpad/attach-smoke.ts http://localhost:13773 RABLQH52KMED
*/
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeSocket from "@effect/platform-node/NodeSocket";
import * as Cause from "effect/Cause";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
import * as Socket from "effect/unstable/socket/Socket";
import { WsRpcGroup } from "@t3tools/contracts";

const baseUrl = process.argv[2];
const pairingToken = process.argv[3];
if (!baseUrl || !pairingToken) {
console.error("usage: node attach-smoke.ts <httpBaseUrl> <pairingToken>");
process.exit(2);
}

interface StepResult {
readonly name: string;
readonly ok: boolean;
readonly detail: string;
readonly ms: number;
}
const results: StepResult[] = [];

const step = <A, E, R>(
name: string,
effect: Effect.Effect<A, E, R>,
options?: { timeoutSeconds?: number; describe?: (value: A) => string },
): Effect.Effect<A | undefined, never, R> =>
Effect.gen(function* () {
const timeoutSeconds = options?.timeoutSeconds ?? 15;
const start = Date.now();
const exit = yield* Effect.exit(
effect.pipe(Effect.timeoutOption(Duration.seconds(timeoutSeconds))),
);
const ms = Date.now() - start;
if (Exit.isSuccess(exit)) {
if (Option.isNone(exit.value)) {
results.push({
name,
ok: false,
detail: `HANG — no response after ${timeoutSeconds}s`,
ms,
});
return undefined;
}
const value = exit.value.value;
results.push({
name,
ok: true,
detail: options?.describe ? options.describe(value) : "ok",
ms,
});
return value;
}
results.push({
name,
ok: false,
detail: Cause.pretty(exit.cause).split("\n").slice(0, 8).join("\n"),
ms,
});
return undefined;
});

const fetchJson = (url: string, init?: RequestInit) =>
Effect.tryPromise({
try: async () => {
const response = await fetch(url, { signal: AbortSignal.timeout(5000), ...init });
const text = await response.text();
let body: any = null;
try {
body = text.length > 0 ? JSON.parse(text) : null;
} catch {
body = text;
}
return { status: response.status, body, setCookie: response.headers.get("set-cookie") };
},
catch: (cause) => new Error(`fetch ${url} failed: ${cause}`),
});

const wsProtocolLayer = (wsUrl: string, cookie: string | null) => {
const ctor = Layer.succeed(
Socket.WebSocketConstructor,
(socketUrl: string, protocols?: string | string[]) =>
new NodeSocket.NodeWS.WebSocket(
socketUrl,
protocols,
cookie ? { headers: { cookie } } : undefined,
) as unknown as globalThis.WebSocket,
);
return RpcClient.layerProtocolSocket().pipe(
Layer.provide(Socket.layerWebSocket(wsUrl).pipe(Layer.provide(ctor))),
Layer.provide(RpcSerialization.layerJson),
);
};

const program = Effect.gen(function* () {
const auth = yield* step(
"auth: pairing token -> session cookie",
Effect.gen(function* () {
for (const path of ["/api/auth/browser-session", "/api/auth/bootstrap"]) {
const result = yield* fetchJson(`${baseUrl}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ credential: pairingToken }),
});
if (result.status === 404) continue;
if (result.status !== 200 || !result.setCookie) {
return yield* Effect.fail(
new Error(`POST ${path} -> ${result.status} ${JSON.stringify(result.body)}`),
);
}
return { path, cookie: result.setCookie.split(";")[0]! };
}
return yield* Effect.fail(new Error("no auth endpoint responded"));
}),
{ describe: (a) => `via ${a.path}` },
);
if (!auth) return;

const ticket = yield* step(
"ws-ticket",
Effect.gen(function* () {
const candidates = [
{ path: "/api/auth/websocket-ticket", field: "ticket", param: "wsTicket" },
{ path: "/api/auth/ws-token", field: "token", param: "wsToken" },
] as const;
for (const candidate of candidates) {
const result = yield* fetchJson(`${baseUrl}${candidate.path}`, {
method: "POST",
headers: { "content-type": "application/json", cookie: auth.cookie },
body: JSON.stringify({}),
});
if (result.status === 404) continue;
const value = result.body?.[candidate.field];
if (result.status !== 200 || typeof value !== "string") {
return yield* Effect.fail(
new Error(`POST ${candidate.path} -> ${result.status} ${JSON.stringify(result.body)}`),
);
}
return { ...candidate, value };
}
return yield* Effect.fail(new Error("no ws credential endpoint responded"));
}),
{ describe: (c) => `via ${c.path}` },
);
if (!ticket) return;

const wsBase = baseUrl.replace(/^http/, "ws");
const wsUrl = `${wsBase}/ws?${ticket.param}=${encodeURIComponent(ticket.value)}`;

yield* Effect.scoped(
RpcClient.make(WsRpcGroup).pipe(
Effect.flatMap((rpcClient) =>
Effect.gen(function* () {
const client = rpcClient as any;
yield* step("getConfig", client["server.getConfig"]({}), {
describe: (cfg: any) =>
`providers=[${(cfg?.providers ?? [])
.map((p: any) => `${p.instanceId}:${p.status}`)
.join(", ")}]`,
});
yield* step(
"subscribeServerConfig snapshot",
client["subscribeServerConfig"]({}).pipe(Stream.take(1), Stream.runCollect),
{
describe: (events: any) =>
`first=${Array.from(events as Iterable<any>)[0]?.type ?? "?"}`,
},
);
yield* step(
"subscribeShell snapshot",
client["orchestration.subscribeShell"]({}).pipe(Stream.take(1), Stream.runCollect),
{ describe: () => "ok" },
);
}),
),
Effect.provide(wsProtocolLayer(wsUrl, auth.cookie)),
),
);
}).pipe(
Effect.onExit((exit) =>
Effect.sync(() => {
console.log("\n===== ATTACH SMOKE RESULTS =====");
for (const r of results) {
console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name} (${r.ms}ms)`);
if (!r.ok || r.detail !== "ok")
console.log(` ${r.detail.split("\n").join("\n ")}`);
}
if (Exit.isFailure(exit)) console.log(`PROGRAM FAILURE:\n${Cause.pretty(exit.cause)}`);
const pass = results.length > 0 && results.every((r) => r.ok) && Exit.isSuccess(exit);
console.log(pass ? "RESULT: PASS" : "RESULT: FAIL");
process.exit(pass ? 0 : 1);
}),
),
);

NodeRuntime.runMain(program.pipe(Effect.provide(Layer.mergeAll(NodeServices.layer))));
72 changes: 72 additions & 0 deletions apps/server/scratchpad/e2e-browser-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Headless-browser probe for the real dev flow.
* Opens the pairing URL, then the providers settings page, and reports:
* - console errors/warnings
* - failed network requests + hung websockets
* - provider row status text (the "Checking provider status" hang)
* Saves screenshots to /tmp/e2e-probe-*.png
*
* Usage: node scratchpad/e2e-browser-probe.ts <pairingUrl>
*/
import { chromium } from "playwright";

const pairingUrl = process.argv[2];
if (!pairingUrl) {
console.error("usage: node e2e-browser-probe.ts <pairingUrl>");
process.exit(2);
}
const origin = new URL(pairingUrl).origin;

const consoleMessages: string[] = [];
const networkFailures: string[] = [];
const wsEvents: string[] = [];

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();

page.on("console", (message) => {
if (message.type() === "error" || message.type() === "warning") {
consoleMessages.push(`[console.${message.type()}] ${message.text()}`);
}
});
page.on("pageerror", (error) => {
consoleMessages.push(`[pageerror] ${error.message}`);
});
page.on("requestfailed", (request) => {
networkFailures.push(
`[requestfailed] ${request.method()} ${request.url()} -> ${request.failure()?.errorText}`,
);
});
page.on("websocket", (ws) => {
wsEvents.push(`[ws open] ${ws.url()}`);
ws.on("close", () => wsEvents.push(`[ws close] ${ws.url()}`));
ws.on("socketerror", (err) => wsEvents.push(`[ws error] ${ws.url()} ${err}`));
});

console.log(`opening pairing url: ${pairingUrl}`);
await page.goto(pairingUrl, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(5000);
await page.screenshot({ path: "/tmp/e2e-probe-1-after-pairing.png", fullPage: true });
console.log(`after pairing: url=${page.url()}`);

console.log(`opening providers settings: ${origin}/settings/providers`);
await page.goto(`${origin}/settings/providers`, { waitUntil: "domcontentloaded" });
// Give provider statuses ample time to arrive.
await page.waitForTimeout(15000);
await page.screenshot({ path: "/tmp/e2e-probe-2-providers.png", fullPage: true });

const bodyText = await page.evaluate(() => document.body.innerText);
const checking = bodyText.includes("Checking provider status");
console.log("\n===== PROVIDERS PAGE TEXT (first 2000 chars) =====");
console.log(bodyText.slice(0, 2000));
console.log("\n===== DIAGNOSTICS =====");
console.log(`stuck on "Checking provider status": ${checking}`);
console.log("\n-- console errors/warnings --");
for (const m of consoleMessages) console.log(m);
console.log("\n-- network failures --");
for (const m of networkFailures) console.log(m);
console.log("\n-- websocket events --");
for (const m of wsEvents) console.log(m);

await browser.close();
process.exit(checking ? 1 : 0);
Loading
Loading