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
3 changes: 2 additions & 1 deletion apps/server/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
import { T3xLayerLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import { T3xLayerLive, T3xRoutesLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
Expand DownExpand Up@@ -361,6 +361,7 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(environmentAuthenticatedAuthLayer),
),
otlpTracesProxyRouteLayer,
T3xRoutesLive, // t3x fork seam — all fork-local HTTP routes fan in here
assetRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -255,4 +255,51 @@ describe("AutoResumeReactor (integration)", () => {
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("does NOT schedule for a thread whose auto-resume is switched off", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);
yield* store.setEnabled("thread-1", false);

yield* Effect.gen(function* () {
yield* settle; // detection runs against the pre-loaded rejection

assert.strictEqual(
(yield* store.listPending).length,
0,
"a disabled thread must never schedule a resume",
);
// Disabling is a deliberate user action, so it must not post timeline noise either.
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.activity.append");

yield* advancePastResume;
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("cancels an already-scheduled resume when the thread is switched off mid-wait", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);

yield* Effect.gen(function* () {
yield* settle;
assert.strictEqual((yield* store.listPending).length, 1, "precondition: it scheduled");

// The switch is flipped off *after* scheduling but *before* the window reopens.
// fireOne must re-read the record rather than trust the scheduling-time value.
yield* store.setEnabled("thread-1", false);

yield* advancePastResume;

assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
assert.strictEqual((yield* store.listPending).length, 0, "pending must be cleared");
assert.strictEqual(
yield* store.countFiredSince("thread-1", 0),
0,
"a cancellation must not burn one of the 24h attempts",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
});
18 changes: 18 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,6 +133,9 @@ const makeSupervisor = Effect.gen(function* () {

const threadId = event.threadId;
const record = yield* store.getThread(threadId);
// Per-thread switch. A thread the user turned off never schedules, and posts no
// timeline note: they disabled it deliberately, so an activity row would be noise.
if (!record.enabled) return;
const nowMs = yield* Clock.currentTimeMillis;
const firedRecently = yield* store.countFiredSince(threadId, nowMs - BACKOFF_LOOKBACK_MS);
const firedInCapWindow = yield* store.countFiredSince(threadId, nowMs - CAP_WINDOW_MS);
Expand DownExpand Up@@ -183,6 +186,21 @@ const makeSupervisor = Effect.gen(function* () {
return;
}

// The user can switch a thread off *after* its resume was scheduled, so re-read the
// record here rather than trusting the value seen at scheduling time, and treat a
// disabled thread as a cancellation (drop the pending resume, say so once).
const record = yield* store.getThread(pending.threadId);
if (!record.enabled) {
yield* store.clearPending(pending.threadId);
yield* appendActivity(
thread.id,
"info",
"t3x.auto-resume.cancelled",
"Auto-resume cancelled: turned off for this thread.",
);
return;
}

const reason = cancelReason(thread, pending.baseline);
if (reason) {
yield* store.clearPending(pending.threadId);
Expand Down
216 changes: 216 additions & 0 deletions apps/server/src/t3x/autoResume/http.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Route-level tests for `/api/t3x/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.
*
* Scope note: because auth is mocked, these tests do NOT prove that the route's
* `authenticateWithOperateScope` 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.
*/
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 * as NodePath from "node:path";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import { autoResumeRouteLayer } from "./http.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./state.ts";

const PATH = "/api/t3x/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);
});

/** 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>,
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: "t3x-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(served), Effect.provide(FetchHttpClient.layer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);

describe("/api/t3x/auto-resume", () => {
it("rejects a bad credential with 401/403 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}`,
);
}),
));

it("rejects a session lacking the operate scope", () =>
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}`,
);
}),
));

it("GET defaults a never-seen thread to enabled", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=fresh`);
assert.strictEqual(res.status, 200);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.overridePrompt, null);
assert.strictEqual(body.pending, null);
}),
));

it("GET without a threadId is a 400, not a crash", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(PATH);
assert.strictEqual(res.status, 400);
}),
));

it("POST patches one field without clobbering the other", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const off = yield* postJson(PATH, { threadId: "t1", enabled: false });
assert.strictEqual(off.status, 200);
assert.strictEqual(((yield* off.json) as Record<string, unknown>).enabled, false);

// Writing only overridePrompt must not resurrect `enabled`.
const text = yield* postJson(PATH, { threadId: "t1", overridePrompt: "keep going" });
const textBody = (yield* text.json) as Record<string, unknown>;
assert.strictEqual(textBody.overridePrompt, "keep going");
assert.strictEqual(textBody.enabled, false, "a prompt write must not clobber enabled");

// …and writing only `enabled` must not wipe the prompt.
const on = yield* postJson(PATH, { threadId: "t1", enabled: true });
const onBody = (yield* on.json) as Record<string, unknown>;
assert.strictEqual(onBody.enabled, true);
assert.strictEqual(onBody.overridePrompt, "keep going", "toggle must not clobber prompt");
}),
));

// This route is what makes threadId caller-controlled; before it, ids only ever came
// from provider events. A prototype-chain id used to resolve on Object.prototype, which
// 500'd on read and persisted a malformed record that wiped the whole state file on the
// 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(),
Effect.gen(function* () {
for (const hostile of ["constructor", "__proto__", "toString"]) {
const res = yield* getJson(`${PATH}?threadId=${encodeURIComponent(hostile)}`);
assert.strictEqual(res.status, 200, `GET threadId=${hostile} must not 500`);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.pending, null);
}

const wrote = yield* postJson(PATH, { threadId: "__proto__", enabled: false });
assert.strictEqual(wrote.status, 200, "POST threadId=__proto__ must not 500");
assert.strictEqual(((yield* wrote.json) as Record<string, unknown>).enabled, false);
}),
));

it("POST with a malformed body is a 400, not a 500", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* postJson(PATH, { nope: true });
assert.strictEqual(res.status, 400);
}),
));
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
3 changes: 2 additions & 1 deletion apps/server/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
import { T3xLayerLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import { T3xLayerLive, T3xRoutesLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
Expand DownExpand Up@@ -361,6 +361,7 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(environmentAuthenticatedAuthLayer),
),
otlpTracesProxyRouteLayer,
T3xRoutesLive, // t3x fork seam — all fork-local HTTP routes fan in here
assetRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -255,4 +255,51 @@ describe("AutoResumeReactor (integration)", () => {
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("does NOT schedule for a thread whose auto-resume is switched off", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);
yield* store.setEnabled("thread-1", false);

yield* Effect.gen(function* () {
yield* settle; // detection runs against the pre-loaded rejection

assert.strictEqual(
(yield* store.listPending).length,
0,
"a disabled thread must never schedule a resume",
);
// Disabling is a deliberate user action, so it must not post timeline noise either.
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.activity.append");

yield* advancePastResume;
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("cancels an already-scheduled resume when the thread is switched off mid-wait", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);

yield* Effect.gen(function* () {
yield* settle;
assert.strictEqual((yield* store.listPending).length, 1, "precondition: it scheduled");

// The switch is flipped off *after* scheduling but *before* the window reopens.
// fireOne must re-read the record rather than trust the scheduling-time value.
yield* store.setEnabled("thread-1", false);

yield* advancePastResume;

assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
assert.strictEqual((yield* store.listPending).length, 0, "pending must be cleared");
assert.strictEqual(
yield* store.countFiredSince("thread-1", 0),
0,
"a cancellation must not burn one of the 24h attempts",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
});
18 changes: 18 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,6 +133,9 @@ const makeSupervisor = Effect.gen(function* () {

const threadId = event.threadId;
const record = yield* store.getThread(threadId);
// Per-thread switch. A thread the user turned off never schedules, and posts no
// timeline note: they disabled it deliberately, so an activity row would be noise.
if (!record.enabled) return;
const nowMs = yield* Clock.currentTimeMillis;
const firedRecently = yield* store.countFiredSince(threadId, nowMs - BACKOFF_LOOKBACK_MS);
const firedInCapWindow = yield* store.countFiredSince(threadId, nowMs - CAP_WINDOW_MS);
Expand DownExpand Up@@ -183,6 +186,21 @@ const makeSupervisor = Effect.gen(function* () {
return;
}

// The user can switch a thread off *after* its resume was scheduled, so re-read the
// record here rather than trusting the value seen at scheduling time, and treat a
// disabled thread as a cancellation (drop the pending resume, say so once).
const record = yield* store.getThread(pending.threadId);
if (!record.enabled) {
yield* store.clearPending(pending.threadId);
yield* appendActivity(
thread.id,
"info",
"t3x.auto-resume.cancelled",
"Auto-resume cancelled: turned off for this thread.",
);
return;
}

const reason = cancelReason(thread, pending.baseline);
if (reason) {
yield* store.clearPending(pending.threadId);
Expand Down
216 changes: 216 additions & 0 deletions apps/server/src/t3x/autoResume/http.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Route-level tests for `/api/t3x/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.
*
* Scope note: because auth is mocked, these tests do NOT prove that the route's
* `authenticateWithOperateScope` 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.
*/
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 * as NodePath from "node:path";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import { autoResumeRouteLayer } from "./http.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./state.ts";

const PATH = "/api/t3x/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);
});

/** 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>,
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: "t3x-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(served), Effect.provide(FetchHttpClient.layer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);

describe("/api/t3x/auto-resume", () => {
it("rejects a bad credential with 401/403 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}`,
);
}),
));

it("rejects a session lacking the operate scope", () =>
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}`,
);
}),
));

it("GET defaults a never-seen thread to enabled", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=fresh`);
assert.strictEqual(res.status, 200);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.overridePrompt, null);
assert.strictEqual(body.pending, null);
}),
));

it("GET without a threadId is a 400, not a crash", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(PATH);
assert.strictEqual(res.status, 400);
}),
));

it("POST patches one field without clobbering the other", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const off = yield* postJson(PATH, { threadId: "t1", enabled: false });
assert.strictEqual(off.status, 200);
assert.strictEqual(((yield* off.json) as Record<string, unknown>).enabled, false);

// Writing only overridePrompt must not resurrect `enabled`.
const text = yield* postJson(PATH, { threadId: "t1", overridePrompt: "keep going" });
const textBody = (yield* text.json) as Record<string, unknown>;
assert.strictEqual(textBody.overridePrompt, "keep going");
assert.strictEqual(textBody.enabled, false, "a prompt write must not clobber enabled");

// …and writing only `enabled` must not wipe the prompt.
const on = yield* postJson(PATH, { threadId: "t1", enabled: true });
const onBody = (yield* on.json) as Record<string, unknown>;
assert.strictEqual(onBody.enabled, true);
assert.strictEqual(onBody.overridePrompt, "keep going", "toggle must not clobber prompt");
}),
));

// This route is what makes threadId caller-controlled; before it, ids only ever came
// from provider events. A prototype-chain id used to resolve on Object.prototype, which
// 500'd on read and persisted a malformed record that wiped the whole state file on the
// 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(),
Effect.gen(function* () {
for (const hostile of ["constructor", "__proto__", "toString"]) {
const res = yield* getJson(`${PATH}?threadId=${encodeURIComponent(hostile)}`);
assert.strictEqual(res.status, 200, `GET threadId=${hostile} must not 500`);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.pending, null);
}

const wrote = yield* postJson(PATH, { threadId: "__proto__", enabled: false });
assert.strictEqual(wrote.status, 200, "POST threadId=__proto__ must not 500");
assert.strictEqual(((yield* wrote.json) as Record<string, unknown>).enabled, false);
}),
));

it("POST with a malformed body is a 400, not a 500", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* postJson(PATH, { nope: true });
assert.strictEqual(res.status, 400);
}),
));
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
3 changes: 2 additions & 1 deletion apps/server/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
import { T3xLayerLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import { T3xLayerLive, T3xRoutesLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
Expand DownExpand Up@@ -361,6 +361,7 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(environmentAuthenticatedAuthLayer),
),
otlpTracesProxyRouteLayer,
T3xRoutesLive, // t3x fork seam — all fork-local HTTP routes fan in here
assetRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -255,4 +255,51 @@ describe("AutoResumeReactor (integration)", () => {
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("does NOT schedule for a thread whose auto-resume is switched off", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);
yield* store.setEnabled("thread-1", false);

yield* Effect.gen(function* () {
yield* settle; // detection runs against the pre-loaded rejection

assert.strictEqual(
(yield* store.listPending).length,
0,
"a disabled thread must never schedule a resume",
);
// Disabling is a deliberate user action, so it must not post timeline noise either.
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.activity.append");

yield* advancePastResume;
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("cancels an already-scheduled resume when the thread is switched off mid-wait", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);

yield* Effect.gen(function* () {
yield* settle;
assert.strictEqual((yield* store.listPending).length, 1, "precondition: it scheduled");

// The switch is flipped off *after* scheduling but *before* the window reopens.
// fireOne must re-read the record rather than trust the scheduling-time value.
yield* store.setEnabled("thread-1", false);

yield* advancePastResume;

assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
assert.strictEqual((yield* store.listPending).length, 0, "pending must be cleared");
assert.strictEqual(
yield* store.countFiredSince("thread-1", 0),
0,
"a cancellation must not burn one of the 24h attempts",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
});
18 changes: 18 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,6 +133,9 @@ const makeSupervisor = Effect.gen(function* () {

const threadId = event.threadId;
const record = yield* store.getThread(threadId);
// Per-thread switch. A thread the user turned off never schedules, and posts no
// timeline note: they disabled it deliberately, so an activity row would be noise.
if (!record.enabled) return;
const nowMs = yield* Clock.currentTimeMillis;
const firedRecently = yield* store.countFiredSince(threadId, nowMs - BACKOFF_LOOKBACK_MS);
const firedInCapWindow = yield* store.countFiredSince(threadId, nowMs - CAP_WINDOW_MS);
Expand DownExpand Up@@ -183,6 +186,21 @@ const makeSupervisor = Effect.gen(function* () {
return;
}

// The user can switch a thread off *after* its resume was scheduled, so re-read the
// record here rather than trusting the value seen at scheduling time, and treat a
// disabled thread as a cancellation (drop the pending resume, say so once).
const record = yield* store.getThread(pending.threadId);
if (!record.enabled) {
yield* store.clearPending(pending.threadId);
yield* appendActivity(
thread.id,
"info",
"t3x.auto-resume.cancelled",
"Auto-resume cancelled: turned off for this thread.",
);
return;
}

const reason = cancelReason(thread, pending.baseline);
if (reason) {
yield* store.clearPending(pending.threadId);
Expand Down
216 changes: 216 additions & 0 deletions apps/server/src/t3x/autoResume/http.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Route-level tests for `/api/t3x/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.
*
* Scope note: because auth is mocked, these tests do NOT prove that the route's
* `authenticateWithOperateScope` 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.
*/
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 * as NodePath from "node:path";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import { autoResumeRouteLayer } from "./http.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./state.ts";

const PATH = "/api/t3x/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);
});

/** 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>,
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: "t3x-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(served), Effect.provide(FetchHttpClient.layer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);

describe("/api/t3x/auto-resume", () => {
it("rejects a bad credential with 401/403 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}`,
);
}),
));

it("rejects a session lacking the operate scope", () =>
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}`,
);
}),
));

it("GET defaults a never-seen thread to enabled", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=fresh`);
assert.strictEqual(res.status, 200);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.overridePrompt, null);
assert.strictEqual(body.pending, null);
}),
));

it("GET without a threadId is a 400, not a crash", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(PATH);
assert.strictEqual(res.status, 400);
}),
));

it("POST patches one field without clobbering the other", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const off = yield* postJson(PATH, { threadId: "t1", enabled: false });
assert.strictEqual(off.status, 200);
assert.strictEqual(((yield* off.json) as Record<string, unknown>).enabled, false);

// Writing only overridePrompt must not resurrect `enabled`.
const text = yield* postJson(PATH, { threadId: "t1", overridePrompt: "keep going" });
const textBody = (yield* text.json) as Record<string, unknown>;
assert.strictEqual(textBody.overridePrompt, "keep going");
assert.strictEqual(textBody.enabled, false, "a prompt write must not clobber enabled");

// …and writing only `enabled` must not wipe the prompt.
const on = yield* postJson(PATH, { threadId: "t1", enabled: true });
const onBody = (yield* on.json) as Record<string, unknown>;
assert.strictEqual(onBody.enabled, true);
assert.strictEqual(onBody.overridePrompt, "keep going", "toggle must not clobber prompt");
}),
));

// This route is what makes threadId caller-controlled; before it, ids only ever came
// from provider events. A prototype-chain id used to resolve on Object.prototype, which
// 500'd on read and persisted a malformed record that wiped the whole state file on the
// 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(),
Effect.gen(function* () {
for (const hostile of ["constructor", "__proto__", "toString"]) {
const res = yield* getJson(`${PATH}?threadId=${encodeURIComponent(hostile)}`);
assert.strictEqual(res.status, 200, `GET threadId=${hostile} must not 500`);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.pending, null);
}

const wrote = yield* postJson(PATH, { threadId: "__proto__", enabled: false });
assert.strictEqual(wrote.status, 200, "POST threadId=__proto__ must not 500");
assert.strictEqual(((yield* wrote.json) as Record<string, unknown>).enabled, false);
}),
));

it("POST with a malformed body is a 400, not a 500", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* postJson(PATH, { nope: true });
assert.strictEqual(res.status, 400);
}),
));
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
3 changes: 2 additions & 1 deletion apps/server/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
import { T3xLayerLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import { T3xLayerLive, T3xRoutesLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
Expand DownExpand Up@@ -361,6 +361,7 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(environmentAuthenticatedAuthLayer),
),
otlpTracesProxyRouteLayer,
T3xRoutesLive, // t3x fork seam — all fork-local HTTP routes fan in here
assetRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -255,4 +255,51 @@ describe("AutoResumeReactor (integration)", () => {
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("does NOT schedule for a thread whose auto-resume is switched off", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);
yield* store.setEnabled("thread-1", false);

yield* Effect.gen(function* () {
yield* settle; // detection runs against the pre-loaded rejection

assert.strictEqual(
(yield* store.listPending).length,
0,
"a disabled thread must never schedule a resume",
);
// Disabling is a deliberate user action, so it must not post timeline noise either.
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.activity.append");

yield* advancePastResume;
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("cancels an already-scheduled resume when the thread is switched off mid-wait", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);

yield* Effect.gen(function* () {
yield* settle;
assert.strictEqual((yield* store.listPending).length, 1, "precondition: it scheduled");

// The switch is flipped off *after* scheduling but *before* the window reopens.
// fireOne must re-read the record rather than trust the scheduling-time value.
yield* store.setEnabled("thread-1", false);

yield* advancePastResume;

assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
assert.strictEqual((yield* store.listPending).length, 0, "pending must be cleared");
assert.strictEqual(
yield* store.countFiredSince("thread-1", 0),
0,
"a cancellation must not burn one of the 24h attempts",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
});
18 changes: 18 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,6 +133,9 @@ const makeSupervisor = Effect.gen(function* () {

const threadId = event.threadId;
const record = yield* store.getThread(threadId);
// Per-thread switch. A thread the user turned off never schedules, and posts no
// timeline note: they disabled it deliberately, so an activity row would be noise.
if (!record.enabled) return;
const nowMs = yield* Clock.currentTimeMillis;
const firedRecently = yield* store.countFiredSince(threadId, nowMs - BACKOFF_LOOKBACK_MS);
const firedInCapWindow = yield* store.countFiredSince(threadId, nowMs - CAP_WINDOW_MS);
Expand DownExpand Up@@ -183,6 +186,21 @@ const makeSupervisor = Effect.gen(function* () {
return;
}

// The user can switch a thread off *after* its resume was scheduled, so re-read the
// record here rather than trusting the value seen at scheduling time, and treat a
// disabled thread as a cancellation (drop the pending resume, say so once).
const record = yield* store.getThread(pending.threadId);
if (!record.enabled) {
yield* store.clearPending(pending.threadId);
yield* appendActivity(
thread.id,
"info",
"t3x.auto-resume.cancelled",
"Auto-resume cancelled: turned off for this thread.",
);
return;
}

const reason = cancelReason(thread, pending.baseline);
if (reason) {
yield* store.clearPending(pending.threadId);
Expand Down
216 changes: 216 additions & 0 deletions apps/server/src/t3x/autoResume/http.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Route-level tests for `/api/t3x/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.
*
* Scope note: because auth is mocked, these tests do NOT prove that the route's
* `authenticateWithOperateScope` 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.
*/
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 * as NodePath from "node:path";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import { autoResumeRouteLayer } from "./http.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./state.ts";

const PATH = "/api/t3x/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);
});

/** 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>,
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: "t3x-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(served), Effect.provide(FetchHttpClient.layer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);

describe("/api/t3x/auto-resume", () => {
it("rejects a bad credential with 401/403 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}`,
);
}),
));

it("rejects a session lacking the operate scope", () =>
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}`,
);
}),
));

it("GET defaults a never-seen thread to enabled", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=fresh`);
assert.strictEqual(res.status, 200);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.overridePrompt, null);
assert.strictEqual(body.pending, null);
}),
));

it("GET without a threadId is a 400, not a crash", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(PATH);
assert.strictEqual(res.status, 400);
}),
));

it("POST patches one field without clobbering the other", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const off = yield* postJson(PATH, { threadId: "t1", enabled: false });
assert.strictEqual(off.status, 200);
assert.strictEqual(((yield* off.json) as Record<string, unknown>).enabled, false);

// Writing only overridePrompt must not resurrect `enabled`.
const text = yield* postJson(PATH, { threadId: "t1", overridePrompt: "keep going" });
const textBody = (yield* text.json) as Record<string, unknown>;
assert.strictEqual(textBody.overridePrompt, "keep going");
assert.strictEqual(textBody.enabled, false, "a prompt write must not clobber enabled");

// …and writing only `enabled` must not wipe the prompt.
const on = yield* postJson(PATH, { threadId: "t1", enabled: true });
const onBody = (yield* on.json) as Record<string, unknown>;
assert.strictEqual(onBody.enabled, true);
assert.strictEqual(onBody.overridePrompt, "keep going", "toggle must not clobber prompt");
}),
));

// This route is what makes threadId caller-controlled; before it, ids only ever came
// from provider events. A prototype-chain id used to resolve on Object.prototype, which
// 500'd on read and persisted a malformed record that wiped the whole state file on the
// 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(),
Effect.gen(function* () {
for (const hostile of ["constructor", "__proto__", "toString"]) {
const res = yield* getJson(`${PATH}?threadId=${encodeURIComponent(hostile)}`);
assert.strictEqual(res.status, 200, `GET threadId=${hostile} must not 500`);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.pending, null);
}

const wrote = yield* postJson(PATH, { threadId: "__proto__", enabled: false });
assert.strictEqual(wrote.status, 200, "POST threadId=__proto__ must not 500");
assert.strictEqual(((yield* wrote.json) as Record<string, unknown>).enabled, false);
}),
));

it("POST with a malformed body is a 400, not a 500", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* postJson(PATH, { nope: true });
assert.strictEqual(res.status, 400);
}),
));
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
3 changes: 2 additions & 1 deletion apps/server/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
import { T3xLayerLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import { T3xLayerLive, T3xRoutesLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
Expand DownExpand Up@@ -361,6 +361,7 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(environmentAuthenticatedAuthLayer),
),
otlpTracesProxyRouteLayer,
T3xRoutesLive, // t3x fork seam — all fork-local HTTP routes fan in here
assetRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -255,4 +255,51 @@ describe("AutoResumeReactor (integration)", () => {
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("does NOT schedule for a thread whose auto-resume is switched off", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);
yield* store.setEnabled("thread-1", false);

yield* Effect.gen(function* () {
yield* settle; // detection runs against the pre-loaded rejection

assert.strictEqual(
(yield* store.listPending).length,
0,
"a disabled thread must never schedule a resume",
);
// Disabling is a deliberate user action, so it must not post timeline noise either.
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.activity.append");

yield* advancePastResume;
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("cancels an already-scheduled resume when the thread is switched off mid-wait", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);

yield* Effect.gen(function* () {
yield* settle;
assert.strictEqual((yield* store.listPending).length, 1, "precondition: it scheduled");

// The switch is flipped off *after* scheduling but *before* the window reopens.
// fireOne must re-read the record rather than trust the scheduling-time value.
yield* store.setEnabled("thread-1", false);

yield* advancePastResume;

assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
assert.strictEqual((yield* store.listPending).length, 0, "pending must be cleared");
assert.strictEqual(
yield* store.countFiredSince("thread-1", 0),
0,
"a cancellation must not burn one of the 24h attempts",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
});
18 changes: 18 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,6 +133,9 @@ const makeSupervisor = Effect.gen(function* () {

const threadId = event.threadId;
const record = yield* store.getThread(threadId);
// Per-thread switch. A thread the user turned off never schedules, and posts no
// timeline note: they disabled it deliberately, so an activity row would be noise.
if (!record.enabled) return;
const nowMs = yield* Clock.currentTimeMillis;
const firedRecently = yield* store.countFiredSince(threadId, nowMs - BACKOFF_LOOKBACK_MS);
const firedInCapWindow = yield* store.countFiredSince(threadId, nowMs - CAP_WINDOW_MS);
Expand DownExpand Up@@ -183,6 +186,21 @@ const makeSupervisor = Effect.gen(function* () {
return;
}

// The user can switch a thread off *after* its resume was scheduled, so re-read the
// record here rather than trusting the value seen at scheduling time, and treat a
// disabled thread as a cancellation (drop the pending resume, say so once).
const record = yield* store.getThread(pending.threadId);
if (!record.enabled) {
yield* store.clearPending(pending.threadId);
yield* appendActivity(
thread.id,
"info",
"t3x.auto-resume.cancelled",
"Auto-resume cancelled: turned off for this thread.",
);
return;
}

const reason = cancelReason(thread, pending.baseline);
if (reason) {
yield* store.clearPending(pending.threadId);
Expand Down
216 changes: 216 additions & 0 deletions apps/server/src/t3x/autoResume/http.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Route-level tests for `/api/t3x/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.
*
* Scope note: because auth is mocked, these tests do NOT prove that the route's
* `authenticateWithOperateScope` 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.
*/
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 * as NodePath from "node:path";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import { autoResumeRouteLayer } from "./http.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./state.ts";

const PATH = "/api/t3x/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);
});

/** 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>,
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: "t3x-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(served), Effect.provide(FetchHttpClient.layer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);

describe("/api/t3x/auto-resume", () => {
it("rejects a bad credential with 401/403 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}`,
);
}),
));

it("rejects a session lacking the operate scope", () =>
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}`,
);
}),
));

it("GET defaults a never-seen thread to enabled", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=fresh`);
assert.strictEqual(res.status, 200);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.overridePrompt, null);
assert.strictEqual(body.pending, null);
}),
));

it("GET without a threadId is a 400, not a crash", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(PATH);
assert.strictEqual(res.status, 400);
}),
));

it("POST patches one field without clobbering the other", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const off = yield* postJson(PATH, { threadId: "t1", enabled: false });
assert.strictEqual(off.status, 200);
assert.strictEqual(((yield* off.json) as Record<string, unknown>).enabled, false);

// Writing only overridePrompt must not resurrect `enabled`.
const text = yield* postJson(PATH, { threadId: "t1", overridePrompt: "keep going" });
const textBody = (yield* text.json) as Record<string, unknown>;
assert.strictEqual(textBody.overridePrompt, "keep going");
assert.strictEqual(textBody.enabled, false, "a prompt write must not clobber enabled");

// …and writing only `enabled` must not wipe the prompt.
const on = yield* postJson(PATH, { threadId: "t1", enabled: true });
const onBody = (yield* on.json) as Record<string, unknown>;
assert.strictEqual(onBody.enabled, true);
assert.strictEqual(onBody.overridePrompt, "keep going", "toggle must not clobber prompt");
}),
));

// This route is what makes threadId caller-controlled; before it, ids only ever came
// from provider events. A prototype-chain id used to resolve on Object.prototype, which
// 500'd on read and persisted a malformed record that wiped the whole state file on the
// 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(),
Effect.gen(function* () {
for (const hostile of ["constructor", "__proto__", "toString"]) {
const res = yield* getJson(`${PATH}?threadId=${encodeURIComponent(hostile)}`);
assert.strictEqual(res.status, 200, `GET threadId=${hostile} must not 500`);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.pending, null);
}

const wrote = yield* postJson(PATH, { threadId: "__proto__", enabled: false });
assert.strictEqual(wrote.status, 200, "POST threadId=__proto__ must not 500");
assert.strictEqual(((yield* wrote.json) as Record<string, unknown>).enabled, false);
}),
));

it("POST with a malformed body is a 400, not a 500", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* postJson(PATH, { nope: true });
assert.strictEqual(res.status, 400);
}),
));
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
3 changes: 2 additions & 1 deletion apps/server/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
import { T3xLayerLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import { T3xLayerLive, T3xRoutesLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
Expand DownExpand Up@@ -361,6 +361,7 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(environmentAuthenticatedAuthLayer),
),
otlpTracesProxyRouteLayer,
T3xRoutesLive, // t3x fork seam — all fork-local HTTP routes fan in here
assetRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -255,4 +255,51 @@ describe("AutoResumeReactor (integration)", () => {
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("does NOT schedule for a thread whose auto-resume is switched off", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);
yield* store.setEnabled("thread-1", false);

yield* Effect.gen(function* () {
yield* settle; // detection runs against the pre-loaded rejection

assert.strictEqual(
(yield* store.listPending).length,
0,
"a disabled thread must never schedule a resume",
);
// Disabling is a deliberate user action, so it must not post timeline noise either.
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.activity.append");

yield* advancePastResume;
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("cancels an already-scheduled resume when the thread is switched off mid-wait", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);

yield* Effect.gen(function* () {
yield* settle;
assert.strictEqual((yield* store.listPending).length, 1, "precondition: it scheduled");

// The switch is flipped off *after* scheduling but *before* the window reopens.
// fireOne must re-read the record rather than trust the scheduling-time value.
yield* store.setEnabled("thread-1", false);

yield* advancePastResume;

assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
assert.strictEqual((yield* store.listPending).length, 0, "pending must be cleared");
assert.strictEqual(
yield* store.countFiredSince("thread-1", 0),
0,
"a cancellation must not burn one of the 24h attempts",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
});
18 changes: 18 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,6 +133,9 @@ const makeSupervisor = Effect.gen(function* () {

const threadId = event.threadId;
const record = yield* store.getThread(threadId);
// Per-thread switch. A thread the user turned off never schedules, and posts no
// timeline note: they disabled it deliberately, so an activity row would be noise.
if (!record.enabled) return;
const nowMs = yield* Clock.currentTimeMillis;
const firedRecently = yield* store.countFiredSince(threadId, nowMs - BACKOFF_LOOKBACK_MS);
const firedInCapWindow = yield* store.countFiredSince(threadId, nowMs - CAP_WINDOW_MS);
Expand DownExpand Up@@ -183,6 +186,21 @@ const makeSupervisor = Effect.gen(function* () {
return;
}

// The user can switch a thread off *after* its resume was scheduled, so re-read the
// record here rather than trusting the value seen at scheduling time, and treat a
// disabled thread as a cancellation (drop the pending resume, say so once).
const record = yield* store.getThread(pending.threadId);
if (!record.enabled) {
yield* store.clearPending(pending.threadId);
yield* appendActivity(
thread.id,
"info",
"t3x.auto-resume.cancelled",
"Auto-resume cancelled: turned off for this thread.",
);
return;
}

const reason = cancelReason(thread, pending.baseline);
if (reason) {
yield* store.clearPending(pending.threadId);
Expand Down
216 changes: 216 additions & 0 deletions apps/server/src/t3x/autoResume/http.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Route-level tests for `/api/t3x/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.
*
* Scope note: because auth is mocked, these tests do NOT prove that the route's
* `authenticateWithOperateScope` 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.
*/
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 * as NodePath from "node:path";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import { autoResumeRouteLayer } from "./http.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./state.ts";

const PATH = "/api/t3x/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);
});

/** 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>,
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: "t3x-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(served), Effect.provide(FetchHttpClient.layer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);

describe("/api/t3x/auto-resume", () => {
it("rejects a bad credential with 401/403 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}`,
);
}),
));

it("rejects a session lacking the operate scope", () =>
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}`,
);
}),
));

it("GET defaults a never-seen thread to enabled", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=fresh`);
assert.strictEqual(res.status, 200);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.overridePrompt, null);
assert.strictEqual(body.pending, null);
}),
));

it("GET without a threadId is a 400, not a crash", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(PATH);
assert.strictEqual(res.status, 400);
}),
));

it("POST patches one field without clobbering the other", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const off = yield* postJson(PATH, { threadId: "t1", enabled: false });
assert.strictEqual(off.status, 200);
assert.strictEqual(((yield* off.json) as Record<string, unknown>).enabled, false);

// Writing only overridePrompt must not resurrect `enabled`.
const text = yield* postJson(PATH, { threadId: "t1", overridePrompt: "keep going" });
const textBody = (yield* text.json) as Record<string, unknown>;
assert.strictEqual(textBody.overridePrompt, "keep going");
assert.strictEqual(textBody.enabled, false, "a prompt write must not clobber enabled");

// …and writing only `enabled` must not wipe the prompt.
const on = yield* postJson(PATH, { threadId: "t1", enabled: true });
const onBody = (yield* on.json) as Record<string, unknown>;
assert.strictEqual(onBody.enabled, true);
assert.strictEqual(onBody.overridePrompt, "keep going", "toggle must not clobber prompt");
}),
));

// This route is what makes threadId caller-controlled; before it, ids only ever came
// from provider events. A prototype-chain id used to resolve on Object.prototype, which
// 500'd on read and persisted a malformed record that wiped the whole state file on the
// 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(),
Effect.gen(function* () {
for (const hostile of ["constructor", "__proto__", "toString"]) {
const res = yield* getJson(`${PATH}?threadId=${encodeURIComponent(hostile)}`);
assert.strictEqual(res.status, 200, `GET threadId=${hostile} must not 500`);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.pending, null);
}

const wrote = yield* postJson(PATH, { threadId: "__proto__", enabled: false });
assert.strictEqual(wrote.status, 200, "POST threadId=__proto__ must not 500");
assert.strictEqual(((yield* wrote.json) as Record<string, unknown>).enabled, false);
}),
));

it("POST with a malformed body is a 400, not a 500", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* postJson(PATH, { nope: true });
assert.strictEqual(res.status, 400);
}),
));
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
3 changes: 2 additions & 1 deletion apps/server/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
import { T3xLayerLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import { T3xLayerLive, T3xRoutesLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
Expand DownExpand Up@@ -361,6 +361,7 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(environmentAuthenticatedAuthLayer),
),
otlpTracesProxyRouteLayer,
T3xRoutesLive, // t3x fork seam — all fork-local HTTP routes fan in here
assetRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -255,4 +255,51 @@ describe("AutoResumeReactor (integration)", () => {
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("does NOT schedule for a thread whose auto-resume is switched off", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);
yield* store.setEnabled("thread-1", false);

yield* Effect.gen(function* () {
yield* settle; // detection runs against the pre-loaded rejection

assert.strictEqual(
(yield* store.listPending).length,
0,
"a disabled thread must never schedule a resume",
);
// Disabling is a deliberate user action, so it must not post timeline noise either.
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.activity.append");

yield* advancePastResume;
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("cancels an already-scheduled resume when the thread is switched off mid-wait", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);

yield* Effect.gen(function* () {
yield* settle;
assert.strictEqual((yield* store.listPending).length, 1, "precondition: it scheduled");

// The switch is flipped off *after* scheduling but *before* the window reopens.
// fireOne must re-read the record rather than trust the scheduling-time value.
yield* store.setEnabled("thread-1", false);

yield* advancePastResume;

assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
assert.strictEqual((yield* store.listPending).length, 0, "pending must be cleared");
assert.strictEqual(
yield* store.countFiredSince("thread-1", 0),
0,
"a cancellation must not burn one of the 24h attempts",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
});
18 changes: 18 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,6 +133,9 @@ const makeSupervisor = Effect.gen(function* () {

const threadId = event.threadId;
const record = yield* store.getThread(threadId);
// Per-thread switch. A thread the user turned off never schedules, and posts no
// timeline note: they disabled it deliberately, so an activity row would be noise.
if (!record.enabled) return;
const nowMs = yield* Clock.currentTimeMillis;
const firedRecently = yield* store.countFiredSince(threadId, nowMs - BACKOFF_LOOKBACK_MS);
const firedInCapWindow = yield* store.countFiredSince(threadId, nowMs - CAP_WINDOW_MS);
Expand DownExpand Up@@ -183,6 +186,21 @@ const makeSupervisor = Effect.gen(function* () {
return;
}

// The user can switch a thread off *after* its resume was scheduled, so re-read the
// record here rather than trusting the value seen at scheduling time, and treat a
// disabled thread as a cancellation (drop the pending resume, say so once).
const record = yield* store.getThread(pending.threadId);
if (!record.enabled) {
yield* store.clearPending(pending.threadId);
yield* appendActivity(
thread.id,
"info",
"t3x.auto-resume.cancelled",
"Auto-resume cancelled: turned off for this thread.",
);
return;
}

const reason = cancelReason(thread, pending.baseline);
if (reason) {
yield* store.clearPending(pending.threadId);
Expand Down
216 changes: 216 additions & 0 deletions apps/server/src/t3x/autoResume/http.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Route-level tests for `/api/t3x/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.
*
* Scope note: because auth is mocked, these tests do NOT prove that the route's
* `authenticateWithOperateScope` 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.
*/
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 * as NodePath from "node:path";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import { autoResumeRouteLayer } from "./http.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./state.ts";

const PATH = "/api/t3x/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);
});

/** 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>,
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: "t3x-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(served), Effect.provide(FetchHttpClient.layer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);

describe("/api/t3x/auto-resume", () => {
it("rejects a bad credential with 401/403 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}`,
);
}),
));

it("rejects a session lacking the operate scope", () =>
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}`,
);
}),
));

it("GET defaults a never-seen thread to enabled", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=fresh`);
assert.strictEqual(res.status, 200);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.overridePrompt, null);
assert.strictEqual(body.pending, null);
}),
));

it("GET without a threadId is a 400, not a crash", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(PATH);
assert.strictEqual(res.status, 400);
}),
));

it("POST patches one field without clobbering the other", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const off = yield* postJson(PATH, { threadId: "t1", enabled: false });
assert.strictEqual(off.status, 200);
assert.strictEqual(((yield* off.json) as Record<string, unknown>).enabled, false);

// Writing only overridePrompt must not resurrect `enabled`.
const text = yield* postJson(PATH, { threadId: "t1", overridePrompt: "keep going" });
const textBody = (yield* text.json) as Record<string, unknown>;
assert.strictEqual(textBody.overridePrompt, "keep going");
assert.strictEqual(textBody.enabled, false, "a prompt write must not clobber enabled");

// …and writing only `enabled` must not wipe the prompt.
const on = yield* postJson(PATH, { threadId: "t1", enabled: true });
const onBody = (yield* on.json) as Record<string, unknown>;
assert.strictEqual(onBody.enabled, true);
assert.strictEqual(onBody.overridePrompt, "keep going", "toggle must not clobber prompt");
}),
));

// This route is what makes threadId caller-controlled; before it, ids only ever came
// from provider events. A prototype-chain id used to resolve on Object.prototype, which
// 500'd on read and persisted a malformed record that wiped the whole state file on the
// 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(),
Effect.gen(function* () {
for (const hostile of ["constructor", "__proto__", "toString"]) {
const res = yield* getJson(`${PATH}?threadId=${encodeURIComponent(hostile)}`);
assert.strictEqual(res.status, 200, `GET threadId=${hostile} must not 500`);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.pending, null);
}

const wrote = yield* postJson(PATH, { threadId: "__proto__", enabled: false });
assert.strictEqual(wrote.status, 200, "POST threadId=__proto__ must not 500");
assert.strictEqual(((yield* wrote.json) as Record<string, unknown>).enabled, false);
}),
));

it("POST with a malformed body is a 400, not a 500", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* postJson(PATH, { nope: true });
assert.strictEqual(res.status, 400);
}),
));
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
3 changes: 2 additions & 1 deletion apps/server/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
import { T3xLayerLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import { T3xLayerLive, T3xRoutesLive } from "./t3x/index.ts"; // t3x fork seam — all fork-local features fan in here
import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
Expand DownExpand Up@@ -361,6 +361,7 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(environmentAuthenticatedAuthLayer),
),
otlpTracesProxyRouteLayer,
T3xRoutesLive, // t3x fork seam — all fork-local HTTP routes fan in here
assetRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -255,4 +255,51 @@ describe("AutoResumeReactor (integration)", () => {
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("does NOT schedule for a thread whose auto-resume is switched off", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);
yield* store.setEnabled("thread-1", false);

yield* Effect.gen(function* () {
yield* settle; // detection runs against the pre-loaded rejection

assert.strictEqual(
(yield* store.listPending).length,
0,
"a disabled thread must never schedule a resume",
);
// Disabling is a deliberate user action, so it must not post timeline noise either.
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.activity.append");

yield* advancePastResume;
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("cancels an already-scheduled resume when the thread is switched off mid-wait", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]);

yield* Effect.gen(function* () {
yield* settle;
assert.strictEqual((yield* store.listPending).length, 1, "precondition: it scheduled");

// The switch is flipped off *after* scheduling but *before* the window reopens.
// fireOne must re-read the record rather than trust the scheduling-time value.
yield* store.setEnabled("thread-1", false);

yield* advancePastResume;

assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");
assert.strictEqual((yield* store.listPending).length, 0, "pending must be cleared");
assert.strictEqual(
yield* store.countFiredSince("thread-1", 0),
0,
"a cancellation must not burn one of the 24h attempts",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
});
18 changes: 18 additions & 0 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,6 +133,9 @@ const makeSupervisor = Effect.gen(function* () {

const threadId = event.threadId;
const record = yield* store.getThread(threadId);
// Per-thread switch. A thread the user turned off never schedules, and posts no
// timeline note: they disabled it deliberately, so an activity row would be noise.
if (!record.enabled) return;
const nowMs = yield* Clock.currentTimeMillis;
const firedRecently = yield* store.countFiredSince(threadId, nowMs - BACKOFF_LOOKBACK_MS);
const firedInCapWindow = yield* store.countFiredSince(threadId, nowMs - CAP_WINDOW_MS);
Expand DownExpand Up@@ -183,6 +186,21 @@ const makeSupervisor = Effect.gen(function* () {
return;
}

// The user can switch a thread off *after* its resume was scheduled, so re-read the
// record here rather than trusting the value seen at scheduling time, and treat a
// disabled thread as a cancellation (drop the pending resume, say so once).
const record = yield* store.getThread(pending.threadId);
if (!record.enabled) {
yield* store.clearPending(pending.threadId);
yield* appendActivity(
thread.id,
"info",
"t3x.auto-resume.cancelled",
"Auto-resume cancelled: turned off for this thread.",
);
return;
}

const reason = cancelReason(thread, pending.baseline);
if (reason) {
yield* store.clearPending(pending.threadId);
Expand Down
216 changes: 216 additions & 0 deletions apps/server/src/t3x/autoResume/http.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Route-level tests for `/api/t3x/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.
*
* Scope note: because auth is mocked, these tests do NOT prove that the route's
* `authenticateWithOperateScope` 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.
*/
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 * as NodePath from "node:path";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import { autoResumeRouteLayer } from "./http.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./state.ts";

const PATH = "/api/t3x/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);
});

/** 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>,
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: "t3x-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(served), Effect.provide(FetchHttpClient.layer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);

describe("/api/t3x/auto-resume", () => {
it("rejects a bad credential with 401/403 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}`,
);
}),
));

it("rejects a session lacking the operate scope", () =>
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}`,
);
}),
));

it("GET defaults a never-seen thread to enabled", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(`${PATH}?threadId=fresh`);
assert.strictEqual(res.status, 200);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.overridePrompt, null);
assert.strictEqual(body.pending, null);
}),
));

it("GET without a threadId is a 400, not a crash", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const res = yield* getJson(PATH);
assert.strictEqual(res.status, 400);
}),
));

it("POST patches one field without clobbering the other", () =>
withRoute(
authOk(),
Effect.gen(function* () {
const off = yield* postJson(PATH, { threadId: "t1", enabled: false });
assert.strictEqual(off.status, 200);
assert.strictEqual(((yield* off.json) as Record<string, unknown>).enabled, false);

// Writing only overridePrompt must not resurrect `enabled`.
const text = yield* postJson(PATH, { threadId: "t1", overridePrompt: "keep going" });
const textBody = (yield* text.json) as Record<string, unknown>;
assert.strictEqual(textBody.overridePrompt, "keep going");
assert.strictEqual(textBody.enabled, false, "a prompt write must not clobber enabled");

// …and writing only `enabled` must not wipe the prompt.
const on = yield* postJson(PATH, { threadId: "t1", enabled: true });
const onBody = (yield* on.json) as Record<string, unknown>;
assert.strictEqual(onBody.enabled, true);
assert.strictEqual(onBody.overridePrompt, "keep going", "toggle must not clobber prompt");
}),
));

// This route is what makes threadId caller-controlled; before it, ids only ever came
// from provider events. A prototype-chain id used to resolve on Object.prototype, which
// 500'd on read and persisted a malformed record that wiped the whole state file on the
// 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(),
Effect.gen(function* () {
for (const hostile of ["constructor", "__proto__", "toString"]) {
const res = yield* getJson(`${PATH}?threadId=${encodeURIComponent(hostile)}`);
assert.strictEqual(res.status, 200, `GET threadId=${hostile} must not 500`);
const body = (yield* res.json) as Record<string, unknown>;
assert.strictEqual(body.enabled, true);
assert.strictEqual(body.pending, null);
}

const wrote = yield* postJson(PATH, { threadId: "__proto__", enabled: false });
assert.strictEqual(wrote.status, 200, "POST threadId=__proto__ must not 500");
assert.strictEqual(((yield* wrote.json) as Record<string, unknown>).enabled, false);
}),
));

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