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
11 changes: 8 additions & 3 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,9 +467,14 @@ machine with `environmentId` and `environmentLabel`, and `environments` lists an
was skipped (disconnected or without read access) or failed, so a missing chat is not mistaken
for one that does not exist. Pass `environmentId` to list one machine.

A chat's `profileSnapshot` names the agent and revision it was created with (`profileId`,
`profileName`, `revision`), not the agent's instructions. Pass `includeSystemPrompt: true` to
`t3_list_agents` when you need each agent's current prompt, for example before `t3_update_agent`.
`t3_list_threads` returns the 20 most recently updated chats unless you pass `limit` (up to
100). When `hasMore` is true, pass `nextCursor` back as `cursor` with the same filters for the
next page. `updatedAfter`, `updatedBefore`, and `createdAfter` take ISO times to pick a window.
Each row is a summary; `t3_get_thread` returns the whole chat.

A chat's `profileId` and `profileName` name the agent it was created with, not the agent's
instructions. Pass `includeSystemPrompt: true` to `t3_list_agents` when you need each agent's
current prompt, for example before `t3_update_agent`.

### Answer an agent's questions through MCP

Expand Down
6 changes: 4 additions & 2 deletions packages/mcp-gateway/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { GatewayError, GATEWAY_THREAD_EXECUTION_STATES } from "./port.ts";
import {
callGatewayTool,
handoffInputSchema,
threadListWindowFields,
threadTaskInputFields,
type GatewayInvocation,
type GatewayToolContext,
Expand Down Expand Up @@ -249,7 +250,7 @@ export const TOOL_SPECS = {
{ environmentId: discoveryEnvironmentId, ...optionalRequestContext },
],
t3_list_threads: [
"List chats across every connected T3 environment (or one), optionally filtered by agent profileId, project, parentThreadId, active/settled state, and executionState. state=active means unsettled (it still includes completed or stopped chats); use executionState to select running or waiting-input/waiting-approval work explicitly. Finished chats remain active until manually settled or automatically settled after their idle age (3 days by default); new activity and un-settling reset the idle clock. Manually settling a parent chat also settles its child chats. hasPendingUserInput marks a chat waiting on a question; pass includeQuestions to attach each one's pendingQuestions (full text and options, as t3_get_pending_questions returns). profileSnapshot names the chat's agent; read its instructions with t3_list_agents includeSystemPrompt.",
"List chats across every connected T3 environment (or one), most recently updated first. Returns the latest 20 unless you pass limit (max 100). When hasMore is true, pass nextCursor back as cursor with the same filters for the next page. Narrow with agent profileId, project, parentThreadId, active/settled state, executionState, and the updatedAfter, updatedBefore, and createdAfter ISO times; use updatedBefore or cursor to reach older chats. state=active means unsettled (it still includes completed or stopped chats); use executionState to select running or waiting-input/waiting-approval work explicitly. Finished chats remain active until manually settled or automatically settled after their idle age (3 days by default); new activity and un-settling reset the idle clock. Manually settling a parent chat also settles its child chats. Rows are summaries: read one chat with t3_get_thread or its messages with t3_get_messages. hasPendingUserInput marks a chat waiting on a question; pass includeQuestions to attach each one's pendingQuestions (full text and options, as t3_get_pending_questions returns). profileId and profileName name the chat's agent; read its instructions with t3_list_agents includeSystemPrompt.",
{
environmentId: discoveryEnvironmentId,
state: z.enum(["all", "active", "settled"]).optional(),
Expand All @@ -272,6 +273,7 @@ export const TOOL_SPECS = {
.boolean()
.optional()
.describe("Attach pendingQuestions to chats waiting on a question."),
...threadListWindowFields,
...optionalRequestContext,
},
],
Expand Down Expand Up @@ -396,7 +398,7 @@ export const TOOL_SPECS = {
{ environmentId, ...threadTaskInputFields.update },
],
t3_task_watch: [
"Wait up to timeoutMs (max 60000) for changes to tasks you own or work on after afterCursor, returning as soon as one changes. Omit afterCursor for the current tasks and cursor. Wakes already arrive as messages; use this only when this turn must wait.",
"Wait up to timeoutMs (max 60000) for changes to tasks you own or work on after afterCursor, returning as soon as one changes. Omit afterCursor for the current tasks and cursor. Task changes never send you a message or wake you after your turn ends; this call only holds the current turn until one arrives.",
{ environmentId, ...threadTaskInputFields.watch },
],
t3_task_board: [
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-gateway/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,12 +277,12 @@ describe("MCP gateway server", () => {
expect(result.structuredContent).toEqual({
schemaVersion: "3",
serverTime: expect.any(String),
data: { items: [], snapshotAt: "snapshot-1" },
data: { items: [], hasMore: false, nextCursor: null, snapshotAt: "snapshot-1" },
});
expect(JSON.parse((result.content as Array<{ text: string }>)[0]!.text)).toEqual({
schemaVersion: "3",
serverTime: expect.any(String),
data: { snapshotAt: "snapshot-1" },
data: { hasMore: false, snapshotAt: "snapshot-1" },
});

await client.close();
Expand Down
180 changes: 174 additions & 6 deletions packages/mcp-gateway/src/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2559,9 +2559,19 @@ describe("gateway coordinator operations", () => {
port.listThreads = async () => ({
snapshotAt: "now",
items: [
{ id: "running", projectId: "p", settledAt: null, status: "running" },
{ id: "waiting", projectId: "p", settledAt: null, status: "waiting-input" },
{ id: "completed", projectId: "p", settledAt: null, status: "completed" },
{ id: "running", settledAt: null, status: "running", updatedAt: "2026-09-07T03:00:00Z" },
{
id: "waiting",
settledAt: null,
status: "waiting-input",
updatedAt: "2026-09-07T02:00:00Z",
},
{
id: "completed",
settledAt: null,
status: "completed",
updatedAt: "2026-09-07T01:00:00Z",
},
{
id: "settled-done",
projectId: "p",
Expand Down Expand Up @@ -3212,8 +3222,18 @@ describe("pending question tools", () => {
port.listThreads = async () => ({
snapshotAt: "runtime",
items: [
{ id: "chat", hasPendingUserInput: true, status: "waiting-input" },
{ id: "busy", hasPendingUserInput: false, status: "running" },
{
id: "chat",
hasPendingUserInput: true,
status: "waiting-input",
updatedAt: "2026-09-07T02:00:00Z",
},
{
id: "busy",
hasPendingUserInput: false,
status: "running",
updatedAt: "2026-09-07T01:00:00Z",
},
],
});
const listed = (await callGatewayTool({ port, grants }, "t3_list_threads", {
Expand All @@ -3225,6 +3245,145 @@ describe("pending question tools", () => {
});
});

describe("bounded thread list", () => {
// 30 chats per machine, one per minute; both machines share every timestamp so ties cross pages.
const minute = (n: number) => `2026-09-07T00:${String(n).padStart(2, "0")}:00.000Z`;
const chatId = (environmentId: string, n: number) =>
`${environmentId}-${String(n).padStart(2, "0")}`;
const newestFirst = (count: number) =>
Array.from({ length: count }, (_, k) => [chatId("a", 29 - k), chatId("b", 29 - k)]).flat();
const chats = (environmentId: string) =>
Array.from({ length: 30 }, (_, n) => ({
id: chatId(environmentId, n),
projectId: n % 2 === 0 ? "even" : "odd",
title: `Chat ${n}`,
status: n % 3 === 0 ? "running" : "completed",
settledAt: null,
hasPendingUserInput: n === 29,
profileSnapshot: { profileId: n % 5 === 0 ? "review" : "code", profileName: "Code" },
modelSelection: { instanceId: "codex", model: "gpt-5", options: [{ id: "effort" }] },
latestRunId: `run-${n}`,
createdAt: minute(n),
updatedAt: minute(n),
}));
function boundedContext() {
const getThread = vi.fn(async () => ({ pendingQuestions: [] }));
const port: GatewayRuntimePort = {
...makePort(),
listEnvironments: async () =>
["a", "b"].map((environmentId) => ({
environmentId,
label: environmentId.toUpperCase(),
targetKind: "relay" as const,
connectionState: "connected" as const,
})),
listThreads: async (environmentId) => ({ snapshotAt: "now", items: chats(environmentId) }),
getThread,
};
return { port, getThread, grants: { a: ["read"], b: ["read"] } as const };
}
type Listed = {
items: ReadonlyArray<Record<string, unknown>>;
hasMore: boolean;
nextCursor: string | null;
};
const list = (context: ReturnType<typeof boundedContext>, input: Record<string, unknown> = {}) =>
callGatewayTool(context, "t3_list_threads", input) as Promise<Listed>;
const ids = (page: Listed) => page.items.map((row) => row.id);

it("returns the latest 20 compact rows by default and pages without overlap or gaps", async () => {
const context = boundedContext();
const first = await list(context);
expect(ids(first)).toEqual(newestFirst(10));
expect(first.hasMore).toBe(true);
expect(first.items[0]).toEqual({
id: "a-29",
environmentId: "a",
environmentLabel: "A",
projectId: "odd",
title: "Chat 29",
status: "completed",
hasPendingUserInput: true,
hasPendingApprovals: undefined,
profileId: "code",
profileName: "Code",
providerInstanceId: "codex",
model: "gpt-5",
parentThreadId: null,
parentEnvironmentId: null,
settledAt: null,
createdAt: minute(29),
updatedAt: minute(29),
});

const seen: unknown[] = [];
let cursor: string | undefined;
let pages = 0;
do {
const page = await list(context, { limit: 7, ...(cursor ? { cursor } : {}) });
seen.push(...ids(page));
cursor = page.nextCursor ?? undefined;
expect(page.hasMore).toBe(cursor !== undefined);
pages += 1;
} while (cursor !== undefined);
expect(pages).toBe(9);
expect(seen).toEqual(newestFirst(30));

await expect(list(context, { limit: 101 })).rejects.toMatchObject({ code: "invalid_input" });
await expect(list(context, { cursor: "not-a-cursor" })).rejects.toMatchObject({
code: "invalid_input",
});
});

it("applies time windows and combined filters across environments before truncating", async () => {
const context = boundedContext();
expect(ids(await list(context, { updatedAfter: minute(28) }))).toEqual([
"a-29",
"b-29",
"a-28",
"b-28",
]);
expect(ids(await list(context, { updatedBefore: minute(2), limit: 100 }))).toEqual([
"a-01",
"b-01",
"a-00",
"b-00",
]);
expect(
ids(await list(context, { createdAfter: minute(27), updatedBefore: minute(29) })),
).toEqual(["a-28", "b-28", "a-27", "b-27"]);
await expect(list(context, { updatedAfter: "yesterday" })).rejects.toMatchObject({
code: "invalid_input",
});

// Matches sit at n = 0, 6, 12, 18, 24 on each machine; oldest ones still reach the page.
const combined = await list(context, {
projectId: "even",
executionState: "running",
profileId: "code",
limit: 5,
});
expect(ids(combined)).toEqual(["a-24", "b-24", "a-18", "b-18", "a-12"]);
const rest = await list(context, {
projectId: "even",
executionState: "running",
profileId: "code",
cursor: combined.nextCursor,
});
expect(ids(rest)).toEqual(["b-12", "a-06", "b-06"]);
expect(rest.hasMore).toBe(false);
});

it("reads pending questions only for chats on the returned page", async () => {
const context = boundedContext();
const page = await list(context, { includeQuestions: true, updatedBefore: minute(29) });
expect(page.items).toHaveLength(20);
expect(context.getThread).not.toHaveBeenCalled();
await list(context, { includeQuestions: true, limit: 1 });
expect(context.getThread).toHaveBeenCalledExactlyOnceWith("a", "a-29");
});
});

describe("cross-environment discovery", () => {
const profile = (profileId: string, environmentIds?: ReadonlyArray<string>): GatewayProfile => ({
profileId,
Expand All @@ -3237,14 +3396,21 @@ describe("cross-environment discovery", () => {
});
const threadsByEnvironment: Record<string, ReadonlyArray<Record<string, unknown>>> = {
macbook: [
{ id: "mac-idle", projectId: "t3", status: "idle", profileSnapshot: { profileId: "code" } },
{
id: "mac-idle",
projectId: "t3",
status: "idle",
profileSnapshot: { profileId: "code" },
updatedAt: "2026-09-07T02:00:00Z",
},
],
"dev-box": [
{
id: "tensorfold",
projectId: "tf",
status: "running",
profileSnapshot: { profileId: "code" },
updatedAt: "2026-09-07T01:00:00Z",
},
],
};
Expand Down Expand Up @@ -3344,6 +3510,8 @@ describe("cross-environment discovery", () => {
expect(listed).toEqual({
snapshotAt: "now",
items: [expect.objectContaining({ id: "tensorfold", environmentLabel: "dev-box" })],
hasMore: false,
nextCursor: null,
});
await expect(
callGatewayTool(context, "t3_list_threads", { environmentId: "phone" }),
Expand Down
Loading
Loading