Skip to content

Commit 955b4cc

Browse files
t3dotggclaude
andauthored
feat(cli): start, message, and read threads from the terminal with t3 thread (#22732)
Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent 28a8d2b commit 955b4cc

8 files changed

Lines changed: 1041 additions & 100 deletions

File tree

‎apps/server/src/auth/EnvironmentAuth.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,11 @@ export class EnvironmentAuth extends Context.Service<
558558
readonly access: AuthMcpClientAccess;
559559
readonly client: AuthClientMetadata;
560560
}) => Effect.Effect<
561-
{ readonly token: string; readonly expiresAt: DateTime.DateTime },
561+
{
562+
readonly sessionId: AuthSessionId;
563+
readonly token: string;
564+
readonly expiresAt: DateTime.DateTime;
565+
},
562566
ServerAuthInternalError
563567
>;
564568
/**
@@ -1292,7 +1296,11 @@ export const make = Effect.gen(function* () {
12921296
client: { ...input.client, label: input.label, deviceType: "bot" },
12931297
})
12941298
.pipe(
1295-
Effect.map((issued) => ({ token: issued.token, expiresAt: issued.expiresAt })),
1299+
Effect.map((issued) => ({
1300+
sessionId: issued.sessionId,
1301+
token: issued.token,
1302+
expiresAt: issued.expiresAt,
1303+
})),
12961304
Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })),
12971305
Effect.withSpan("EnvironmentAuth.issueMcpClientSession"),
12981306
);

‎apps/server/src/binCli.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
1818
import { extendConnectionAttemptTimeout, keepOutboundFetchOnHttp1 } from "./outboundHttp.ts";
1919
import { sharedServerCommandFlags } from "./cli/config.ts";
2020
import { projectCommand } from "./cli/project.ts";
21+
import { threadCommand } from "./cli/thread.ts";
2122
import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts";
2223
import { updateCommand } from "./cli/update.ts";
2324
import { uninstallCommand } from "./cli/uninstall.ts";
@@ -76,6 +77,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
7677
authCommand,
7778
browserCommand,
7879
projectCommand,
80+
threadCommand,
7981
serviceCommand,
8082
updateCommand,
8183
uninstallCommand,

‎apps/server/src/cli/thread.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { assert, it } from "@effect/vitest";
2+
import { RunId, ThreadId } from "@t3tools/contracts";
3+
import * as Effect from "effect/Effect";
4+
5+
import { type ThreadTools, waitForRun } from "./thread.ts";
6+
7+
const threadId = ThreadId.make("thread-1");
8+
const runId = RunId.make("run-1");
9+
10+
/** Answers each tool call with the next scripted response for that tool. */
11+
const scriptedTools = (script: Record<string, Array<unknown>>) => {
12+
const calls: Array<{ readonly tool: string; readonly args: unknown }> = [];
13+
const call = ((tool: string, args: unknown) => {
14+
calls.push({ tool, args });
15+
const next = script[tool]?.shift();
16+
return next === undefined ? Effect.die(`unscripted ${tool} call`) : Effect.succeed(next);
17+
}) as ThreadTools["call"];
18+
return { tools: { call } satisfies ThreadTools, calls };
19+
};
20+
21+
it.effect("--wait follows the sent run to its continuation's terminal status", () =>
22+
Effect.gen(function* () {
23+
const continuation = RunId.make("run-2");
24+
const { tools, calls } = scriptedTools({
25+
t3_thread_wait: [
26+
{ threadId, runId, status: "running", timedOut: true },
27+
{ threadId, runId: continuation, status: "failed", timedOut: false },
28+
],
29+
t3_thread_read: [{ thread: { pendingRequestCount: 0 } }],
30+
});
31+
32+
const outcome = yield* waitForRun(tools, threadId, runId);
33+
34+
assert.deepStrictEqual(outcome, { type: "ended", runId: continuation, status: "failed" });
35+
assert.deepStrictEqual(
36+
calls.filter((entry) => entry.tool === "t3_thread_wait").map((entry) => entry.args),
37+
[
38+
{ threadId, runId, timeoutMs: 15_000 },
39+
{ threadId, runId, timeoutMs: 15_000 },
40+
],
41+
);
42+
}),
43+
);
44+
45+
it.effect("--wait stops when the thread waits for an answer instead of polling forever", () =>
46+
Effect.gen(function* () {
47+
const { tools } = scriptedTools({
48+
t3_thread_wait: [
49+
{ threadId, runId, status: "queued", timedOut: true },
50+
{ threadId, runId, status: "queued", timedOut: true },
51+
],
52+
t3_thread_read: [{ thread: { pendingRequestCount: 1 } }],
53+
});
54+
55+
assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), { type: "needs_input" });
56+
}),
57+
);
58+
59+
it.effect("--wait reports its run's result when a later run is the one waiting", () =>
60+
Effect.gen(function* () {
61+
const { tools } = scriptedTools({
62+
t3_thread_wait: [
63+
{ threadId, runId, status: "running", timedOut: true },
64+
{ threadId, runId, status: "completed", timedOut: false },
65+
],
66+
t3_thread_read: [{ thread: { pendingRequestCount: 1 } }],
67+
});
68+
69+
assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), {
70+
type: "ended",
71+
runId,
72+
status: "completed",
73+
});
74+
}),
75+
);

0 commit comments

Comments
 (0)