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
50 changes: 49 additions & 1 deletion apps/server/src/coil/autoResume/guards.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vite-plus/test";
import type { OrchestrationThread } from "@t3tools/contracts";
import {
type OrchestrationThread,
type OrchestrationThreadShell,
ProjectId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";

import {
cancelReason,
Expand Down Expand Up @@ -123,6 +129,48 @@ describe("simple predicates", () => {
expect(isClaudeThread(makeThread({ providerName: null }))).toBe(false);
});

// A shell carries `session` with the same `OrchestrationSession | null` type the full
// thread does, so it satisfies the guard's `Pick<…, "session">` argument.
//
// Built with NO cast, deliberately: the cast-free literal is the only thing pinning the
// widening. Revert `isClaudeThread` to `(thread: OrchestrationThread)` and this file stops
// type-checking, which is the failure a shell-only caller (the loop supervisor reads the
// shell stream) would otherwise hit at build time instead of here.
it("isClaudeThread accepts a thread shell, not just a full thread", () => {
const shell: OrchestrationThreadShell = {
id: ThreadId.make("thread-1"),
projectId: ProjectId.make("project-1"),
title: "Thread",
modelSelection: { instanceId: ProviderInstanceId.make("claude"), model: "opus" },
runtimeMode: "full-access",
interactionMode: "default",
branch: "feature",
worktreePath: "/repo",
latestTurn: null,
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-02T00:00:00.000Z",
archivedAt: null,
settledOverride: null,
settledAt: null,
session: {
threadId: ThreadId.make("thread-1"),
status: "ready",
providerName: "claudeAgent",
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
updatedAt: "2026-09-02T00:00:00.000Z",
},
latestUserMessageAt: "2026-09-02T00:00:00.000Z",
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
};

expect(isClaudeThread(shell)).toBe(true);
expect(isClaudeThread({ ...shell, session: null })).toBe(false);
});

it("threadIsGone covers deleted / archived", () => {
expect(threadIsGone(makeThread({ deletedAt: "2026-01-01" }))).toBe(true);
expect(threadIsGone(makeThread({ archivedAt: "2026-01-01" }))).toBe(true);
Expand Down
24 changes: 18 additions & 6 deletions apps/server/src/coil/autoResume/guards.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* Pure guard helpers for auto-resume.
*
* All predicates operate on the authoritative `OrchestrationThread` read-model snapshot
* (the full-thread shape, which carries `messages` and `activities` — NOT the shell-only
* derived flags). Keeping them pure makes every guard trivially unit-testable.
* Predicates operate on the authoritative `OrchestrationThread` read-model snapshot (the
* full-thread shape, which carries `messages` and `activities` — NOT the shell-only derived
* flags). The one exception is `isClaudeThread`, which asks for only the field it reads so a
* caller holding a thread shell can reuse it. Keeping them pure makes every guard trivially
* unit-testable.
*
* @module coil/autoResume/guards
*/
Expand Down Expand Up @@ -91,11 +93,21 @@ function isStaleRequestFailureDetail(payload: Record<string, unknown> | null): b

/**
* The thread's session is backed by the Claude driver. The driver slug is
* `"claudeAgent"` (ClaudeAdapter.ts:96); `session.providerName` is set from
* `event.provider` (ProviderRuntimeIngestion.ts:1442).
* `"claudeAgent"`; `session.providerName` is set from `event.provider` in
* `ProviderRuntimeIngestion`.
*/
export const CLAUDE_DRIVER_KIND = "claudeAgent";
export function isClaudeThread(thread: OrchestrationThread): boolean {

/**
* Takes the narrowest shape it reads rather than a whole `OrchestrationThread`, so a caller
* holding only an `OrchestrationThreadShell` can ask too — the shell declares `session` with
* the identical `OrchestrationSession | null` type, so it is structurally assignable and no
* second copy of this predicate has to exist.
*
* Every other guard in this module still takes the full thread, because they read `messages`
* and `activities`, which the shell does not carry.
*/
export function isClaudeThread(thread: Pick<OrchestrationThread, "session">): boolean {
const name = thread.session?.providerName;
return typeof name === "string" && name.toLowerCase() === CLAUDE_DRIVER_KIND.toLowerCase();
}
Expand Down
141 changes: 36 additions & 105 deletions apps/server/src/coil/autoResume/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,145 +3,76 @@
* Route-level tests for `/api/coil/auto-resume`.
*
* Serves ONLY the fork's route layer (not the whole `makeRoutesLayer`) over a real HTTP
* server on an ephemeral port, with `EnvironmentAuth` mocked.
* server on an ephemeral port, with `EnvironmentAuth` mocked (see `coil/http/testAuth.ts`).
*
* Scope note: because auth is mocked, these tests do NOT prove that the route's
* `authenticateWithOperateScope` faithfully mirrors upstream's private
* Scope note: because auth is mocked, these tests do NOT prove that the shared
* `coil/http/auth.ts` helper faithfully mirrors upstream's private
* `authenticateRawRouteWithScope` — that remains a logic mirror tracked in SEAMS.md.
* What they DO prove is that a rejected credential and a missing scope each render as a
* 401/403 through the route's `catchTags` rather than escaping as a 500 or an unhandled
* defect, and that the GET/POST contract round-trips through a real store.
* What they DO prove is that a rejected credential and a missing scope each render through
* the route's `catchTags` rather than escaping as a 500 or an unhandled defect, and that the
* GET/POST contract round-trips through a real store. The helper's own bodies are pinned in
* `coil/http/auth.test.ts`.
*/
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpRouter,
HttpServer,
} from "effect/unstable/http";
import * as NodeHttp from "node:http";
import type { HttpClient, HttpServer } from "effect/unstable/http";
import * as NodePath from "node:path";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import { authFails, authOk, getJson, postJson, runServed, serveRoutes } from "../http/testAuth.ts";
import { autoResumeRouteLayer } from "./http.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./state.ts";

const PATH = "/api/coil/auto-resume";

const authedSession = (scopes: ReadonlyArray<string>) =>
({
sessionId: "test-session",
subject: "test",
method: "bearer-access-token",
scopes,
}) as unknown as EnvironmentAuth.AuthenticatedSession;

/** Mock auth that succeeds with the given scopes. */
const authOk = (scopes: ReadonlyArray<string> = [AuthOrchestrationOperateScope]) =>
Layer.mock(EnvironmentAuth.EnvironmentAuth)({
authenticateHttpRequest: () => Effect.succeed(authedSession(scopes)),
});

/**
* Mock auth that rejects the credential.
*
* Must be a member of the `ServerAuthCredentialError` union
* (`ServerAuthMissingCredentialError | ServerAuthInvalidCredentialError`) — that union is
* what `isServerAuthCredentialError` guards on. An error outside it (and outside
* `ServerAuthInternalError`) matches neither `catchIf` branch and surfaces as a 500. That
* is equally true of upstream's OTLP route, which uses the identical two branches, so it
* is a shared property rather than a fork divergence.
*/
const authRejects = Layer.mock(EnvironmentAuth.EnvironmentAuth)({
authenticateHttpRequest: () =>
Effect.fail(new EnvironmentAuth.ServerAuthInvalidCredentialError({})),
});

const baseUrl = (pathname: string) =>
Effect.gen(function* () {
const server = yield* HttpServer.HttpServer;
const address = server.address as HttpServer.TcpAddress;
return `http://127.0.0.1:${address.port}${pathname}`;
});

const getJson = (pathname: string) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.get(yield* baseUrl(pathname));
});

const postJson = (pathname: string, body: unknown) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
const request = HttpClientRequest.bodyJsonUnsafe(
HttpClientRequest.post(yield* baseUrl(pathname)),
body,
);
return yield* client.execute(request);
});
const authRejects = authFails(new EnvironmentAuth.ServerAuthInvalidCredentialError({}));

/** Serves the route over an ephemeral port with the given auth layer and a real store. */
const withRoute = <A, E>(
authLayer: Layer.Layer<EnvironmentAuth.EnvironmentAuth>,
auth: Layer.Layer<EnvironmentAuth.EnvironmentAuth>,
body: Effect.Effect<A, E, HttpServer.HttpServer | HttpClient.HttpClient>,
): Promise<A> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "coil-http-" });
const storeLayer = Layer.effect(
AutoResumeStore,
makeAutoResumeStore(NodePath.join(root, "state.json")),
);

const served = HttpRouter.serve(autoResumeRouteLayer, {
disableListenLog: true,
disableLogger: true,
}).pipe(
Layer.provide(storeLayer),
Layer.provide(authLayer),
// provideMerge, not provide: the test body needs HttpServer in context to read the
// ephemeral port off `server.address`.
Layer.provideMerge(NodeHttpServer.layer(() => NodeHttp.createServer(), { port: 0 })),
);

return yield* body.pipe(Effect.provide(Layer.merge(served, FetchHttpClient.layer)));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);
) =>
runServed(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "coil-http-" });
const storeLayer = Layer.effect(
AutoResumeStore,
makeAutoResumeStore(NodePath.join(root, "state.json")),
);
return yield* serveRoutes({
routes: autoResumeRouteLayer,
deps: Layer.merge(storeLayer, auth),
body,
});
}),
);

describe("/api/coil/auto-resume", () => {
it("rejects a bad credential with 401/403 instead of leaking a 500", () =>
it("rejects a bad credential with a 401 instead of leaking a 500", () =>
withRoute(
authRejects,
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=thread-a`);
assert.isTrue(
res.status === 401 || res.status === 403,
`expected 401/403 for a rejected credential, got ${res.status}`,
);
assert.strictEqual(res.status, 401);
}),
));

it("rejects a session lacking the operate scope", () =>
it("rejects a session lacking the operate scope with a 403", () =>
withRoute(
authOk([]),
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=thread-a`);
assert.isTrue(
res.status === 401 || res.status === 403,
`expected 401/403 without the operate scope, got ${res.status}`,
);
assert.strictEqual(res.status, 403);
}),
));

it("GET defaults a never-seen thread to enabled", () =>
withRoute(
authOk(),
authOk([AuthOrchestrationOperateScope]),
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=fresh`);
assert.strictEqual(res.status, 200);
Expand All @@ -154,7 +85,7 @@ describe("/api/coil/auto-resume", () => {

it("GET without a threadId is a 400, not a crash", () =>
withRoute(
authOk(),
authOk([AuthOrchestrationOperateScope]),
Effect.gen(function* () {
const res = yield* getJson(PATH);
assert.strictEqual(res.status, 400);
Expand All @@ -163,7 +94,7 @@ describe("/api/coil/auto-resume", () => {

it("POST patches one field without clobbering the other", () =>
withRoute(
authOk(),
authOk([AuthOrchestrationOperateScope]),
Effect.gen(function* () {
const off = yield* postJson(PATH, { threadId: "t1", enabled: false });
assert.strictEqual(off.status, 200);
Expand All @@ -189,7 +120,7 @@ describe("/api/coil/auto-resume", () => {
// next boot. Pinned here at the HTTP boundary, not just in the store.
it("handles a prototype-chain threadId as an ordinary unknown thread", () =>
withRoute(
authOk(),
authOk([AuthOrchestrationOperateScope]),
Effect.gen(function* () {
for (const hostile of ["constructor", "__proto__", "toString"]) {
const res = yield* getJson(`${PATH}?threadId=${encodeURIComponent(hostile)}`);
Expand All @@ -207,7 +138,7 @@ describe("/api/coil/auto-resume", () => {

it("POST with a malformed body is a 400, not a 500", () =>
withRoute(
authOk(),
authOk([AuthOrchestrationOperateScope]),
Effect.gen(function* () {
const res = yield* postJson(PATH, { nope: true });
assert.strictEqual(res.status, 400);
Expand Down
Loading
Loading