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
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const STATUS_LABEL_BY_STATUS: Partial<
> = {
approval: { label: "Approval", className: "text-adaptive-amber-700-300" },
input: { label: "Input", className: "text-adaptive-indigo-600-300" },
question: { label: "? Question", className: "text-adaptive-violet-700-300" },
working: { label: "Working", className: "text-adaptive-sky-600-400" },
waiting: { label: "Waiting", className: "text-adaptive-amber-700-300" },
failed: { label: "Failed", className: "text-adaptive-red-700-300" },
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,28 @@ describe("resolveThreadListV2Status", () => {
expect(shouldShowActionWaitingIndicator(working, "working")).toBe(false);
});

it("shows question attention before active work", () => {
expect(
resolveThreadListV2Status(
makeThread({
id: ThreadId.make("t"),
title: "t",
attention: { kind: "question", raisedAt: NOW },
session: {
threadId: ThreadId.make("t"),
status: "running",
providerName: "Codex",
providerInstanceId: ProviderInstanceId.make("codex"),
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
updatedAt: NOW,
},
}),
),
).toBe("question");
});

it("resolves ready for quiescent threads", () => {
expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
"ready",
Expand Down
14 changes: 12 additions & 2 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ export { snoozeWakeLabel };
* (approval), "in motion" (working), and "broken" (failed). Ready is the
* unlabeled resting state.
*/
export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready";
export type ThreadListV2Status =
| "approval"
| "input"
| "question"
| "working"
| "waiting"
| "failed"
| "ready";
export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";

export type ThreadListV2CleanupAction = "retry-worktree-cleanup" | "keep-worktree";
Expand Down Expand Up @@ -138,7 +145,7 @@ export function resolveThreadListV2Enabled(input: {
export function resolveThreadListV2Status(
thread: Pick<
EnvironmentThreadShell,
"hasPendingApprovals" | "hasPendingUserInput" | "session" | "actionResume"
"actionResume" | "attention" | "hasPendingApprovals" | "hasPendingUserInput" | "session"
>,
): ThreadListV2Status {
if (thread.hasPendingApprovals) {
Expand All @@ -147,6 +154,9 @@ export function resolveThreadListV2Status(
if (thread.hasPendingUserInput) {
return "input";
}
if (thread.attention?.kind === "question") {
return "question";
}
if (thread.session?.status === "running" || thread.session?.status === "starting") {
return "working";
}
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/threadPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
export type ThreadStatusKind =
| "pending-approval"
| "awaiting-input"
| "question"
| "working"
| "waiting"
| "connecting"
Expand Down Expand Up @@ -114,6 +115,18 @@ export function resolveThreadStatus(
};
}

if (thread.attention?.kind === "question") {
Comment thread
lastobelus marked this conversation as resolved.
return {
kind: "question",
label: "Question",
pillClassName: "bg-adaptive-violet-500-a12-a16",
textClassName: "text-adaptive-violet-700-300",
iconColor: "#bf5af2",
iconBackground: "rgba(191,90,242,0.22)",
pulse: false,
};
}

if (thread.session?.status === "running") {
return {
kind: "working",
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ const invocation = {
capabilities: new Set(["preview"] as const),
issuedAt: 1,
};

it("keeps attention-only credentials off the full MCP endpoint", () => {
const attentionOnlyInvocation = { ...invocation, capabilities: new Set<"preview">() };

expect(McpHttpServer.canInvokeMcpEndpoint("/mcp", attentionOnlyInvocation)).toBe(false);
expect(McpHttpServer.canInvokeMcpEndpoint("/mcp/thread", attentionOnlyInvocation)).toBe(true);
expect(McpHttpServer.canInvokeMcpEndpoint("/mcp", invocation)).toBe(true);
});

const client = McpSchema.McpServerClient.of({
clientId: 1,
protocolVersion: "2025-06-18",
Expand Down
105 changes: 67 additions & 38 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
} from "./toolkits/preview/tools.ts";
import { ActionResumeToolkitHandlersLive } from "./toolkits/actionResume/handlers.ts";
import { ActionResumeToolkit } from "./toolkits/actionResume/tools.ts";
import { ThreadAttentionToolkitHandlersLive } from "./toolkits/threadAttention/handlers.ts";
import { ThreadAttentionToolkit } from "./toolkits/threadAttention/tools.ts";

const unauthorized = HttpServerResponse.jsonUnsafe(
{
Expand Down Expand Up @@ -65,37 +67,51 @@ export const normalizeMcpHttpResponse = (
: response;
};

const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe(
Effect.map((registry): McpAuthMiddleware =>
Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) {
const request = yield* HttpServerRequest.HttpServerRequest;
const authorization = request.headers.authorization;
const token =
authorization?.startsWith("Bearer ") === true
? authorization.slice("Bearer ".length).trim()
: "";
const invocation = yield* registry.resolve(token);
if (!invocation) {
// Without this the only symptom of a dead credential is the agent
// quietly losing the whole `t3-code` toolkit for the rest of its
// session, with nothing on the server to explain why.
yield* Effect.logWarning("rejected MCP request with an unusable credential", {
reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token",
});
return unauthorized;
}
return yield* httpEffect.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.map(normalizeMcpHttpResponse),
);
}),
),
Effect.withSpan("McpHttpServer.makeAuthMiddleware"),
);
type McpEndpointPath = "/mcp" | "/mcp/thread";

export const canInvokeMcpEndpoint = (
path: McpEndpointPath,
invocation: McpInvocationContext.McpInvocationScope,
): boolean => path === "/mcp/thread" || invocation.capabilities.has("preview");

const McpAuthMiddlewareLive = HttpRouter.middleware<{
provides: McpInvocationContext.McpInvocationContext;
}>()(makeMcpAuthMiddleware).layer;
const makeMcpAuthMiddleware = (path: McpEndpointPath) =>
McpSessionRegistry.McpSessionRegistry.pipe(
Effect.map((registry): McpAuthMiddleware =>
Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) {
const request = yield* HttpServerRequest.HttpServerRequest;
const authorization = request.headers.authorization;
const token =
authorization?.startsWith("Bearer ") === true
? authorization.slice("Bearer ".length).trim()
: "";
const invocation = yield* registry.resolve(token);
if (!invocation || !canInvokeMcpEndpoint(path, invocation)) {
// Without this the only symptom of a dead credential is the agent
// quietly losing the whole `t3-code` toolkit for the rest of its
// session, with nothing on the server to explain why.
yield* Effect.logWarning("rejected MCP request with an unusable credential", {
reason:
token.length === 0
? "missing_bearer_token"
: invocation
? "insufficient_capability"
: "unknown_or_expired_token",
});
return unauthorized;
}
return yield* httpEffect.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.map(normalizeMcpHttpResponse),
);
}),
),
Effect.withSpan("McpHttpServer.makeAuthMiddleware"),
);

const makeMcpAuthMiddlewareLive = (path: McpEndpointPath) =>
HttpRouter.middleware<{
provides: McpInvocationContext.McpInvocationContext;
}>()(makeMcpAuthMiddleware(path)).layer;

const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
Expand Down Expand Up @@ -221,14 +237,27 @@ export const ActionResumeToolkitRegistrationLive = McpServer.toolkit(ActionResum
Layer.provide(ActionResumeToolkitHandlersLive),
);

const McpTransportLive = McpServer.layerHttp({
name: "T3 Code",
version: packageJson.version,
path: "/mcp",
protocols: [McpProtocol.v2025_06_18],
}).pipe(Layer.provide(McpAuthMiddlewareLive));
const threadAttentionToolkitRegistration = () =>
McpServer.toolkit(ThreadAttentionToolkit).pipe(Layer.provide(ThreadAttentionToolkitHandlersLive));

const makeMcpTransport = (path: McpEndpointPath) =>
McpServer.layerHttp({
name: "T3 Code",
version: packageJson.version,
path,
protocols: [McpProtocol.v2025_06_18],
}).pipe(Layer.provide(makeMcpAuthMiddlewareLive(path)));

export const layer = Layer.mergeAll(
const FullToolkitLive = Layer.mergeAll(
PreviewToolkitRegistrationLive,
ActionResumeToolkitRegistrationLive,
).pipe(Layer.provideMerge(McpTransportLive));
threadAttentionToolkitRegistration(),
).pipe(Layer.provideMerge(makeMcpTransport("/mcp")));

// Sessions created while agent browser access is disabled still receive the
// attention tools, but preview tools stay absent from discovery entirely.
const ThreadAttentionOnlyToolkitLive = threadAttentionToolkitRegistration().pipe(
Layer.provideMerge(makeMcpTransport("/mcp/thread")),
);

export const layer = Layer.mergeAll(FullToolkitLive, ThreadAttentionOnlyToolkitLive);
15 changes: 15 additions & 0 deletions apps/server/src/mcp/McpSessionRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ it.effect("builds MCP endpoints from the bound server host", () =>
}),
);

it.effect("uses the attention-only endpoint when preview access is disabled", () =>
Effect.gen(function* () {
const registry = yield* makeRegistry(() => 1_000);
const issued = yield* registry.issue({
threadId: ThreadId.make("thread-attention-only"),
providerInstanceId: ProviderInstanceId.make("codex"),
enablePreview: false,
});
expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp/thread");

const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
expect((yield* registry.resolve(token))?.capabilities.has("preview")).toBe(false);
}),
);

it.effect("expires credentials once their session stops showing signs of life", () =>
Effect.gen(function* () {
let timestamp = 1_000;
Expand Down
12 changes: 7 additions & 5 deletions apps/server/src/mcp/McpSessionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as McpProviderSession from "./McpProviderSession.ts";
export interface McpCredentialRequest {
readonly threadId: ThreadId;
readonly providerInstanceId: ProviderInstanceId;
readonly enablePreview?: boolean;
}

export interface McpIssuedCredential {
Expand Down Expand Up @@ -98,10 +99,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() });
const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis;
const livenessWindowMs = options.livenessWindowMs ?? DEFAULT_LIVENESS_WINDOW_MS;
const endpoint =
const endpointBase =
httpServer.address._tag === "TcpAddress"
? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp`
: "http://127.0.0.1/mcp";
? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}`
: "http://127.0.0.1";

const hashToken = (token: string) =>
crypto
Expand All @@ -128,7 +129,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
threadId: ThreadId.make(request.threadId),
providerSessionId,
providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
capabilities: new Set(["preview", "action-resume"]),
capabilities:
request.enablePreview === false ? new Set() : new Set(["preview", "action-resume"]),
issuedAt,
};
yield* SynchronizedRef.update(state, ({ records }) => {
Expand All @@ -142,7 +144,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
threadId: scope.threadId,
providerSessionId,
providerInstanceId: scope.providerInstanceId,
endpoint,
endpoint: `${endpointBase}${request.enablePreview === false ? "/mcp/thread" : "/mcp"}`,
authorizationHeader: `Bearer ${rawToken}`,
},
};
Expand Down
63 changes: 63 additions & 0 deletions apps/server/src/mcp/toolkits/threadAttention/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {
EnvironmentId,
ProviderInstanceId,
ThreadId,
type OrchestrationCommand,
} from "@t3tools/contracts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";

import * as McpInvocationContext from "../../McpInvocationContext.ts";
import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts";
import { threadAttentionHandlers } from "./handlers.ts";

it.effect("dispatches attention commands only to the authenticated thread", () => {
const commands: Array<OrchestrationCommand> = [];
const boundThreadId = ThreadId.make("bound-thread");
const engine = OrchestrationEngineService.of({
getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }),
dispatch: (command) =>
Effect.sync(() => {
commands.push(command);
return { sequence: commands.length };
}),
readEvents: () => Stream.empty,
streamDomainEvents: Stream.empty,
subscribeDomainEvents: Effect.succeed(Stream.empty),
latestSequence: Effect.succeed(0),
});
const invocation: McpInvocationContext.McpInvocationScope = {
environmentId: EnvironmentId.make("environment-1"),
threadId: boundThreadId,
providerSessionId: "provider-session-1",
providerInstanceId: ProviderInstanceId.make("codex"),
capabilities: new Set(),
issuedAt: 0,
};

const testLayer = Layer.mergeAll(
NodeServices.layer,
Layer.succeed(OrchestrationEngineService, engine),
Layer.succeed(McpInvocationContext.McpInvocationContext, invocation),
);

return Effect.gen(function* () {
const marked = yield* threadAttentionHandlers.set_thread_attention({
kind: "question",
});
const cleared = yield* threadAttentionHandlers.clear_thread_attention();

expect(marked.attention.kind).toBe("question");
expect(cleared.attention).toBeNull();
expect(commands.map((command) => command.type)).toEqual([
"thread.attention.set",
"thread.attention.clear",
]);
expect(
commands.every((command) => "threadId" in command && command.threadId === boundThreadId),
).toBe(true);
}).pipe(Effect.provide(testLayer));
});
Loading