authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-07 04:33:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 04:33:44-07:00
log611132c171f3a821bd2e32f22261135cef6330ac
tree1610c8c6e8ac33556291a95f04472d972b948ee6
parentcd41c4ada0c70cc2eec95ecd7266f3dab010c58c
signature Signed by PGP key B5690EEEBB952194

feat(server,web,mobile): agents see snooze state and link to threads (#16782)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

18 files changed, 381 insertions(+), 19 deletions(-)

apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+6-1
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 normalizeMarkdownLinkDestination,5 normalizeMarkdownLinkDestination,
6 parseMarkdownFileLink,6 parseMarkdownFileLink,
7} from "@t3tools/client-runtime/markdown-links";7} from "@t3tools/client-runtime/markdown-links";
8import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
8import { videoMimeType } from "@t3tools/shared/video";9import { videoMimeType } from "@t3tools/shared/video";
910
10import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";11import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
...@@ -293,7 +294,11 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese...@@ -293,7 +294,11 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
293294
294 return {295 return {
295 kind: "link",296 kind: "link",
296 href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null,297 // A thread link keeps its href so a press reaches the feed, which opens the thread.
298 href:
299 /^(?:mailto|tel):/i.test(normalized) || parseThreadLinkHref(normalized) !== null
300 ? normalized
301 : null,
297 };302 };
298}303}
299304
apps/mobile/src/features/threads/ThreadFeed.tsx+18-5
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25} from "@t3tools/contracts";25} from "@t3tools/contracts";
26import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";26import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
27import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";27import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
28import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
28import {29import {
29 parseComposerContextHref,30 parseComposerContextHref,
30 collectComposerContextReferences,31 collectComposerContextReferences,
...@@ -1242,16 +1243,20 @@ function useMarkdownStyles(...@@ -1242,16 +1243,20 @@ function useMarkdownStyles(
1242 );1243 );
1243 }1244 }
1244 const linkHref = presentation.href;1245 const linkHref = presentation.href;
1246 // A thread link opens the thread in the app, through the feed's link handler.
1247 const isThreadLink = parseThreadLinkHref(href) !== null;
1245 return (1248 return (
1246 <MarkdownLinkLabelContext.Provider value="other">1249 <MarkdownLinkLabelContext.Provider value="other">
1247 <NativeText1250 <NativeText
1248 className="underline"1251 className="underline"
1249 onPress={1252 onPress={
1250 linkHref1253 isThreadLink
1251 ? () => {1254 ? () => onLinkPress(href)
1252 void tryOpenExternalUrl(linkHref, "markdown-link");1255 : linkHref
1253 }1256 ? () => {
1254 : undefined1257 void tryOpenExternalUrl(linkHref, "markdown-link");
1258 }
1259 : undefined
1255 }1260 }
1256 style={{ color: markdownLinkColor }}1261 style={{ color: markdownLinkColor }}
1257 >1262 >
...@@ -2271,6 +2276,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2271,6 +2276,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2271 const userBubbleColor = theme["--color-user-bubble"];2276 const userBubbleColor = theme["--color-user-bubble"];
2272 const onMarkdownLinkPress = useCallback(2277 const onMarkdownLinkPress = useCallback(
2273 (href: string) => {2278 (href: string) => {
2279 const threadLink = parseThreadLinkHref(href);
2280 if (threadLink) {
2281 navigation.navigate("Thread", {
2282 environmentId: String(threadLink.environmentId),
2283 threadId: String(threadLink.threadId),
2284 });
2285 return;
2286 }
2274 const presentation = resolveMarkdownLinkPresentation(href);2287 const presentation = resolveMarkdownLinkPresentation(href);
2275 if (presentation.kind === "file") {2288 if (presentation.kind === "file") {
2276 const relativePath = resolveWorkspaceRelativeFilePath(2289 const relativePath = resolveWorkspaceRelativeFilePath(
apps/mobile/src/lib/nativeMarkdownText.test.ts+15
...@@ -84,6 +84,21 @@ describe("nativeMarkdownTextRuns", () => {...@@ -84,6 +84,21 @@ describe("nativeMarkdownTextRuns", () => {
84 ]);84 ]);
85 });85 });
8686
87 it("keeps a thread link's href so a press can open the thread", () => {
88 expect(
89 nativeMarkdownTextRuns({
90 type: "paragraph",
91 children: [
92 {
93 type: "link",
94 href: "t3-thread://v1/env/thread-1",
95 children: [{ type: "text", content: "Fix the build" }],
96 },
97 ],
98 }),
99 ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]);
100 });
101
87 it("preserves the destination of a link with a code-formatted label", () => {102 it("preserves the destination of a link with a code-formatted label", () => {
88 expect(103 expect(
89 nativeMarkdownTextRuns({104 nativeMarkdownTextRuns({
apps/server/src/mcp/OrchestratorMcpService.ts+54-5
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 type EnvironmentId,
2 CommandId,3 CommandId,
3 type RunId,4 type RunId,
4 isProviderAvailable,5 isProviderAvailable,
...@@ -58,6 +59,8 @@ import {...@@ -58,6 +59,8 @@ import {
58 ThreadId,59 ThreadId,
59} from "@t3tools/contracts";60} from "@t3tools/contracts";
60import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";61import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
62import { formatThreadLink } from "@t3tools/shared/threadLinks";
63import * as Clock from "effect/Clock";
61import * as Context from "effect/Context";64import * as Context from "effect/Context";
62import * as Crypto from "effect/Crypto";65import * as Crypto from "effect/Crypto";
63import * as DateTime from "effect/DateTime";66import * as DateTime from "effect/DateTime";
...@@ -80,6 +83,7 @@ import {...@@ -80,6 +83,7 @@ import {
80 type DispatchModeRefusal,83 type DispatchModeRefusal,
81} from "../orchestration-v2/DispatchModeLimit.ts";84} from "../orchestration-v2/DispatchModeLimit.ts";
82import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";85import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
86import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts";
83import * as ProjectService from "../project/ProjectService.ts";87import * as ProjectService from "../project/ProjectService.ts";
84import * as ProviderRegistry from "../provider/ProviderRegistry.ts";88import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
85import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";89import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
...@@ -617,9 +621,35 @@ function threadSettlement(...@@ -617,9 +621,35 @@ function threadSettlement(
617 };621 };
618}622}
619623
620function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpThreadListItem {624function threadSnooze(
625 thread: Parameters<typeof isSnoozed>[0],
626 nowMs: number,
627): Pick<OrchestratorMcpThreadListItem, "snoozed" | "snoozedUntil"> {
628 const snoozed = isSnoozed(thread, nowMs);
629 return {
630 snoozed,
631 snoozedUntil:
632 snoozed && thread.snoozedUntil != null ? DateTime.formatIso(thread.snoozedUntil) : null,
633 };
634}
635
636/** Where and when a thread is shown: the environment for its link, and the time for snooze state. */
637interface ThreadViewContext {
638 readonly environmentId: EnvironmentId;
639 readonly nowMs: number;
640}
641
642function listItemFromShell(
643 shell: OrchestrationV2ThreadShell,
644 context: ThreadViewContext,
645): OrchestratorMcpThreadListItem {
621 return {646 return {
622 threadId: shell.id,647 threadId: shell.id,
648 link: formatThreadLink({
649 environmentId: context.environmentId,
650 threadId: shell.id,
651 title: shell.title,
652 }),
623 title: shell.title,653 title: shell.title,
624 createdBy: shell.createdBy,654 createdBy: shell.createdBy,
625 creationSource: shell.creationSource,655 creationSource: shell.creationSource,
...@@ -631,6 +661,7 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh...@@ -631,6 +661,7 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh
631 interactionMode: shell.interactionMode,661 interactionMode: shell.interactionMode,
632 linkedPullRequest: shell.linkedPullRequest ?? null,662 linkedPullRequest: shell.linkedPullRequest ?? null,
633 ...threadSettlement(shell),663 ...threadSettlement(shell),
664 ...threadSnooze(shell, context.nowMs),
634 parentThreadId: shell.lineage.parentThreadId,665 parentThreadId: shell.lineage.parentThreadId,
635 relationshipToParent: shell.lineage.relationshipToParent,666 relationshipToParent: shell.lineage.relationshipToParent,
636 itemCount: shell.visibleItemCount,667 itemCount: shell.visibleItemCount,
...@@ -642,11 +673,18 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh...@@ -642,11 +673,18 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh
642function threadDetail(673function threadDetail(
643 projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">,674 projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">,
644 itemCount: number,675 itemCount: number,
676 shell: OrchestrationV2ThreadShell,
677 context: ThreadViewContext,
645): OrchestratorMcpThreadDetail {678): OrchestratorMcpThreadDetail {
646 const latest = ThreadManagementService.latestRun(projection);679 const latest = ThreadManagementService.latestRun(projection);
647 const active = ThreadManagementService.latestActiveRun(projection);680 const active = ThreadManagementService.latestActiveRun(projection);
648 return {681 return {
649 threadId: projection.thread.id,682 threadId: projection.thread.id,
683 link: formatThreadLink({
684 environmentId: context.environmentId,
685 threadId: projection.thread.id,
686 title: projection.thread.title,
687 }),
650 projectId: projection.thread.projectId,688 projectId: projection.thread.projectId,
651 title: projection.thread.title,689 title: projection.thread.title,
652 createdBy: projection.thread.createdBy,690 createdBy: projection.thread.createdBy,
...@@ -678,6 +716,8 @@ function threadDetail(...@@ -678,6 +716,8 @@ function threadDetail(
678 ).length,716 ).length,
679 archived: projection.thread.archivedAt !== null,717 archived: projection.thread.archivedAt !== null,
680 ...threadSettlement(projection.thread),718 ...threadSettlement(projection.thread),
719 // From the shell, like the list, so read and list agree on snooze state.
720 ...threadSnooze(shell, context.nowMs),
681 createdAt: DateTime.formatIso(projection.thread.createdAt),721 createdAt: DateTime.formatIso(projection.thread.createdAt),
682 updatedAt: DateTime.formatIso(projection.thread.updatedAt),722 updatedAt: DateTime.formatIso(projection.thread.updatedAt),
683 };723 };
...@@ -1024,7 +1064,7 @@ const make = Effect.gen(function* () {...@@ -1024,7 +1064,7 @@ const make = Effect.gen(function* () {
1024 "contextTransfers",1064 "contextTransfers",
1025 ])1065 ])
1026 .pipe(Effect.mapError(threadManagementFailure));1066 .pipe(Effect.mapError(threadManagementFailure));
1027 return { parent, target } as const;1067 return { parent, target, shell } as const;
1028 });1068 });
10291069
1030 const loadProviders = providerRegistry.getProviders;1070 const loadProviders = providerRegistry.getProviders;
...@@ -2226,6 +2266,7 @@ const make = Effect.gen(function* () {...@@ -2226,6 +2266,7 @@ const make = Effect.gen(function* () {
2226 failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`),2266 failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`),
2227 ),2267 ),
2228 );2268 );
2269 const nowMs = yield* Clock.currentTimeMillis;
2229 const statuses = input.statuses === undefined ? null : new Set(input.statuses);2270 const statuses = input.statuses === undefined ? null : new Set(input.statuses);
2230 const titleContains = input.titleContains?.toLocaleLowerCase();2271 const titleContains = input.titleContains?.toLocaleLowerCase();
2231 const filtered = projectThreads2272 const filtered = projectThreads
...@@ -2237,6 +2278,9 @@ const make = Effect.gen(function* () {...@@ -2237,6 +2278,9 @@ const make = Effect.gen(function* () {
2237 (thread) =>2278 (thread) =>
2238 input.settled === undefined || threadSettlement(thread).settled === input.settled,2279 input.settled === undefined || threadSettlement(thread).settled === input.settled,
2239 )2280 )
2281 .filter(
2282 (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed,
2283 )
2240 .filter(2284 .filter(
2241 (thread) =>2285 (thread) =>
2242 titleContains === undefined ||2286 titleContains === undefined ||
...@@ -2249,14 +2293,16 @@ const make = Effect.gen(function* () {...@@ -2249,14 +2293,16 @@ const make = Effect.gen(function* () {
2249 return {2293 return {
2250 projectId,2294 projectId,
2251 currentThreadId: parent?.thread.id ?? null,2295 currentThreadId: parent?.thread.id ?? null,
2252 threads: page.map(listItemFromShell),2296 threads: page.map((shell) =>
2297 listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }),
2298 ),
2253 nextCursor,2299 nextCursor,
2254 total: filtered.length,2300 total: filtered.length,
2255 } satisfies OrchestratorMcpThreadListResult;2301 } satisfies OrchestratorMcpThreadListResult;
2256 }),2302 }),
2257 readThread: (scope, input) =>2303 readThread: (scope, input) =>
2258 Effect.gen(function* () {2304 Effect.gen(function* () {
2259 const { parent, target } = yield* loadReadableThread(scope, input.threadId);2305 const { parent, target, shell } = yield* loadReadableThread(scope, input.threadId);
2260 const view = input.view ?? "messages";2306 const view = input.view ?? "messages";
2261 const afterPosition = input.afterPosition ?? -1;2307 const afterPosition = input.afterPosition ?? -1;
2262 const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT;2308 const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT;
...@@ -2329,7 +2375,10 @@ const make = Effect.gen(function* () {...@@ -2329,7 +2375,10 @@ const make = Effect.gen(function* () {
2329 }2375 }
2330 }2376 }
2331 return {2377 return {
2332 thread: threadDetail(target, timeline.totalItems),2378 thread: threadDetail(target, timeline.totalItems, shell, {
2379 environmentId: scope.environmentId,
2380 nowMs: yield* Clock.currentTimeMillis,
2381 }),
2333 recentRuns: target.runs2382 recentRuns: target.runs
2334 .toSorted((left, right) => right.ordinal - left.ordinal)2383 .toSorted((left, right) => right.ordinal - left.ordinal)
2335 .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)2384 .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+38
...@@ -2232,6 +2232,44 @@ describe("orchestrator MCP toolkit", () => {...@@ -2232,6 +2232,44 @@ describe("orchestrator MCP toolkit", () => {
2232 reason: "user",2232 reason: "user",
2233 });2233 });
22342234
2235 expect(metadataRead.thread).toMatchObject({ snoozed: false, snoozedUntil: null });
2236 expect(metadataRead.thread.link).toBe(
2237 `[Metadata-managed thread](t3-thread://v1/environment%3Amcp-orchestrator/${encodeURIComponent(emptyThread.threadId)})`,
2238 );
2239 yield* orchestrator.dispatch({
2240 type: "thread.snooze",
2241 commandId: CommandId.make("command:mcp-empty:snooze"),
2242 threadId: emptyThread.threadId,
2243 snoozedUntil: "2099-01-01T00:00:00.000Z",
2244 });
2245 const snoozedListCall = yield* invoke("t3_thread_list", { snoozed: true, limit: 100 });
2246 const snoozedList = yield* decodeThreadListResult(
2247 snoozedListCall.structuredContent,
2248 ).pipe(Effect.orDie);
2249 expect(snoozedList.threads.map((thread) => thread.threadId)).toEqual([
2250 emptyThread.threadId,
2251 ]);
2252 expect(snoozedList.threads[0]).toMatchObject({
2253 snoozed: true,
2254 snoozedUntil: "2099-01-01T00:00:00.000Z",
2255 });
2256 const snoozedReadCall = yield* invoke("t3_thread_read", {
2257 threadId: emptyThread.threadId,
2258 });
2259 const snoozedRead = yield* decodeThreadReadResult(
2260 snoozedReadCall.structuredContent,
2261 ).pipe(Effect.orDie);
2262 expect(snoozedRead.thread).toMatchObject({
2263 snoozed: true,
2264 snoozedUntil: "2099-01-01T00:00:00.000Z",
2265 });
2266 yield* orchestrator.dispatch({
2267 type: "thread.unsnooze",
2268 commandId: CommandId.make("command:mcp-empty:unsnooze"),
2269 threadId: emptyThread.threadId,
2270 reason: "user",
2271 });
2272
2235 const unlinkedCall = yield* invoke("t3_thread_update", {2273 const unlinkedCall = yield* invoke("t3_thread_update", {
2236 threadId: emptyThread.threadId,2274 threadId: emptyThread.threadId,
2237 action: "unlink_pull_request",2275 action: "unlink_pull_request",
apps/server/src/mcp/toolkits/orchestrator/tools.ts+2-2
...@@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", {...@@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", {
178178
179const ThreadListTool = Tool.make("t3_thread_list", {179const ThreadListTool = Tool.make("t3_thread_list", {
180 description:180 description:
181 "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list) and paginate with the returned cursor.",181 "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early when it asks for something, fails, or completes. Each thread has a link: paste it when you mention the thread so the user can open it.",
182 parameters: OrchestratorMcpThreadListInput,182 parameters: OrchestratorMcpThreadListInput,
183 success: OrchestratorMcpThreadListResult,183 success: OrchestratorMcpThreadListResult,
184 failure: OrchestratorMcpFailure,184 failure: OrchestratorMcpFailure,
...@@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", {...@@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", {
192192
193const ThreadReadTool = Tool.make("t3_thread_read", {193const ThreadReadTool = Tool.make("t3_thread_read", {
194 description:194 description:
195 "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units.",195 "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread has a link and snooze state: paste the link when you mention the thread so the user can open it.",
196 parameters: OrchestratorMcpThreadReadInput,196 parameters: OrchestratorMcpThreadReadInput,
197 success: OrchestratorMcpThreadReadResult,197 success: OrchestratorMcpThreadReadResult,
198 failure: OrchestratorMcpFailure,198 failure: OrchestratorMcpFailure,
apps/server/src/mcp/toolkits/project/handlers.test.ts+13-3
...@@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread",...@@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread",
69 return Effect.succeed({69 return Effect.succeed({
70 threadId: input.threadId,70 threadId: input.threadId,
71 projection: {71 projection: {
72 thread: { id: input.threadId, projectId, modelSelection },72 thread: { id: input.threadId, projectId, modelSelection, title: input.title },
73 runs: [],73 runs: [],
74 },74 },
75 resumed: false,75 resumed: false,
...@@ -94,7 +94,11 @@ it.effect("attributes a launched thread's first message to the calling thread",...@@ -94,7 +94,11 @@ it.effect("attributes a launched thread's first message to the calling thread",
94 const result = yield* toolkit94 const result = yield* toolkit
95 .handle("t3_thread_launch", { title: "Audit", message: "Review the change" })95 .handle("t3_thread_launch", { title: "Audit", message: "Review the change" })
96 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies));96 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies));
97 expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection });97 expect(result.at(-1)?.result).toMatchObject({
98 projectId,
99 modelSelection,
100 link: expect.stringMatching(/^\[Audit\]\(t3-thread:\/\/v1\/environment\//),
101 });
98 expect(launchedSender).toBe(sourceThreadId);102 expect(launchedSender).toBe(sourceThreadId);
99 }),103 }),
100);104);
...@@ -140,7 +144,12 @@ it.effect("launches a scratch thread into the Scratch project", () =>...@@ -140,7 +144,12 @@ it.effect("launches a scratch thread into the Scratch project", () =>
140 return Effect.succeed({144 return Effect.succeed({
141 threadId: input.threadId,145 threadId: input.threadId,
142 projection: {146 projection: {
143 thread: { id: input.threadId, projectId: input.projectId, modelSelection },147 thread: {
148 id: input.threadId,
149 projectId: input.projectId,
150 modelSelection,
151 title: input.title,
152 },
144 runs: [],153 runs: [],
145 },154 },
146 resumed: false,155 resumed: false,
...@@ -329,6 +338,7 @@ const clientLaunchHarness = (input: {...@@ -329,6 +338,7 @@ const clientLaunchHarness = (input: {
329 id: launch.threadId,338 id: launch.threadId,
330 projectId: launch.projectId,339 projectId: launch.projectId,
331 modelSelection: launch.modelSelection,340 modelSelection: launch.modelSelection,
341 title: launch.title,
332 },342 },
333 runs: [],343 runs: [],
334 },344 },
apps/server/src/mcp/toolkits/project/handlers.ts+7-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";1import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
2import { formatThreadLink } from "@t3tools/shared/threadLinks";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";5import * as Option from "effect/Option";
...@@ -57,7 +58,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {...@@ -57,7 +58,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
57 (input, { runtimeMode, interactionMode }) =>58 (input, { runtimeMode, interactionMode }) =>
58 Effect.gen(function* () {59 Effect.gen(function* () {
59 const context = yield* readCaller();60 const context = yield* readCaller();
60 const { caller } = context;61 const { caller, scope } = context;
61 const commandId = yield* newCommandId();62 const commandId = yield* newCommandId();
62 const threadId = ThreadId.make(commandId);63 const threadId = ThreadId.make(commandId);
63 const messageId = MessageId.make(commandId);64 const messageId = MessageId.make(commandId);
...@@ -146,6 +147,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {...@@ -146,6 +147,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
146 const run = result.projection.runs.find((run) => run.userMessageId === messageId);147 const run = result.projection.runs.find((run) => run.userMessageId === messageId);
147 return {148 return {
148 threadId: thread.id,149 threadId: thread.id,
150 link: formatThreadLink({
151 environmentId: scope.environmentId,
152 threadId: thread.id,
153 title: thread.title,
154 }),
149 projectId: thread.projectId,155 projectId: thread.projectId,
150 modelSelection: thread.modelSelection,156 modelSelection: thread.modelSelection,
151 runId: run?.id ?? null,157 runId: run?.id ?? null,
apps/server/src/mcp/toolkits/project/tools.ts+3-1
...@@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {...@@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {
103const ThreadLaunchTool = Tool.make("t3_thread_launch", {103const ThreadLaunchTool = Tool.make("t3_thread_launch", {
104 ...shared,104 ...shared,
105 description:105 description:
106 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.',106 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. Paste the returned link when you mention the thread. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.',
107 parameters: Schema.Struct({107 parameters: Schema.Struct({
108 projectId: Schema.optional(ProjectId),108 projectId: Schema.optional(ProjectId),
109 scratch: Schema.optional(109 scratch: Schema.optional(
...@@ -132,6 +132,8 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {...@@ -132,6 +132,8 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
132 }),132 }),
133 success: Schema.Struct({133 success: Schema.Struct({
134 threadId: ThreadId,134 threadId: ThreadId,
135 /** Paste this whenever you mention the thread, so the user can click to open it. */
136 link: Schema.String,
135 projectId: ProjectId,137 projectId: ProjectId,
136 modelSelection: ModelSelection,138 modelSelection: ModelSelection,
137 runId: Schema.NullOr(RunId),139 runId: Schema.NullOr(RunId),
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+22
...@@ -1252,3 +1252,25 @@ describe("ThreadSettlementServiceV2 single-thread sweeps", () => {...@@ -1252,3 +1252,25 @@ describe("ThreadSettlementServiceV2 single-thread sweeps", () => {
1252 ),1252 ),
1253 );1253 );
1254});1254});
1255
1256describe("isSnoozed", () => {
1257 const snoozed = { snoozedAt: at(-2 * DAY_MS), snoozedUntil: at(DAY_MS) };
1258 it("wakes for completed work after the snooze, but not an interrupted run", () => {
1259 expect(ThreadSettlementService.isSnoozed(shell(snoozed), NOW_MS)).toBe(true);
1260 expect(
1261 ThreadSettlementService.isSnoozed(
1262 shell({ ...snoozed, status: "interrupted", latestRunCompletedAt: at(-DAY_MS) }),
1263 NOW_MS,
1264 ),
1265 ).toBe(true);
1266 expect(
1267 ThreadSettlementService.isSnoozed(
1268 shell({ ...snoozed, status: "completed", latestRunCompletedAt: at(-DAY_MS) }),
1269 NOW_MS,
1270 ),
1271 ).toBe(false);
1272 expect(
1273 ThreadSettlementService.isSnoozed(shell({ ...snoozed, snoozedUntil: at(-1) }), NOW_MS),
1274 ).toBe(false);
1275 });
1276});
apps/server/src/orchestration-v2/ThreadSettlementService.ts+33
...@@ -163,6 +163,39 @@ export function isAutoSettlementCandidate(...@@ -163,6 +163,39 @@ export function isAutoSettlementCandidate(
163 return wokeOnError || wokeOnCompletion;163 return wokeOnError || wokeOnCompletion;
164}164}
165165
166/**
167 * Whether a thread is parked on its snooze: its wake time is in the future and
168 * it has not raised its hand with a pending request, a fresh failure, or work
169 * that completed after the snooze. Server twin of the client's
170 * `effectiveSnoozed`, so agents and the sidebar agree on what is snoozed. One
171 * difference: a failure counts as fresh when its run completed after the
172 * snooze, like `isAutoSettlementCandidate`. The client compares the shell's
173 * update time, so a rename can wake a failed thread there but not here.
174 */
175export function isSnoozed(
176 thread: Pick<
177 ProjectionStore.ProjectionSettlementCandidate,
178 "snoozedUntil" | "snoozedAt" | "latestRunCompletedAt" | "status" | "pendingRuntimeRequest"
179 >,
180 nowMs: number,
181): boolean {
182 const snoozedUntilMs = toMillis(thread.snoozedUntil);
183 if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return false;
184 if (thread.pendingRuntimeRequest !== null) return false;
185 const snoozedAtMs = toMillis(thread.snoozedAt);
186 const completedAtMs = toMillis(thread.latestRunCompletedAt);
187 const wokeOnError =
188 thread.status === "failed" &&
189 (snoozedAtMs === null || (completedAtMs !== null && completedAtMs > snoozedAtMs));
190 // Like the client, only a run that completed wakes it; an interrupt or cancel does not.
191 const wokeOnCompletion =
192 thread.status === "completed" &&
193 snoozedAtMs !== null &&
194 completedAtMs !== null &&
195 completedAtMs > snoozedAtMs;
196 return !wokeOnError && !wokeOnCompletion;
197}
198
166export function resolveAutoSettlementAt(input: {199export function resolveAutoSettlementAt(input: {
167 readonly thread: ProjectionStore.ProjectionSettlementCandidate;200 readonly thread: ProjectionStore.ProjectionSettlementCandidate;
168 readonly pullRequest: SettlementPullRequest | null;201 readonly pullRequest: SettlementPullRequest | null;
apps/web/src/components/ChatMarkdown.tsx+19-1
...@@ -88,7 +88,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";...@@ -88,7 +88,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
88import remarkBreaks from "remark-breaks";88import remarkBreaks from "remark-breaks";
89import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";89import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
90import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";90import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
91import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks";
91import { AssistantCitationChip } from "./chat/AssistantCitationChip";92import { AssistantCitationChip } from "./chat/AssistantCitationChip";
93import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";
92import remarkGfm from "remark-gfm";94import remarkGfm from "remark-gfm";
93import type { Processor } from "unified";95import type { Processor } from "unified";
94import { isWindowsAbsolutePath } from "@t3tools/shared/path";96import { isWindowsAbsolutePath } from "@t3tools/shared/path";
...@@ -538,7 +540,13 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {...@@ -538,7 +540,13 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
538 },540 },
539 protocols: {541 protocols: {
540 ...defaultSchema.protocols,542 ...defaultSchema.protocols,
541 href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation", "t3-context"],543 href: [
544 ...(defaultSchema.protocols?.href ?? []),
545 "file",
546 "t3-citation",
547 "t3-context",
548 THREAD_LINK_PROTOCOL,
549 ],
542 src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"],550 src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"],
543 },551 },
544} satisfies Parameters<typeof rehypeSanitize>[0];552} satisfies Parameters<typeof rehypeSanitize>[0];
...@@ -2645,6 +2653,7 @@ function useChatMarkdownState({...@@ -2645,6 +2653,7 @@ function useChatMarkdownState({
2645 const markdownUrlTransform = useCallback((href: string) => {2653 const markdownUrlTransform = useCallback((href: string) => {
2646 if (parseAssistantCitationHref(href)) return href;2654 if (parseAssistantCitationHref(href)) return href;
2647 if (parseComposerContextHref(href)) return href;2655 if (parseComposerContextHref(href)) return href;
2656 if (parseThreadLinkHref(href)) return href;
2648 if (isWindowsDrivePathHref(href)) return href;2657 if (isWindowsDrivePathHref(href)) return href;
2649 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);2658 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
2650 }, []);2659 }, []);
...@@ -3107,6 +3116,15 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3107,6 +3116,15 @@ const CHAT_MARKDOWN_COMPONENTS = {
3107 } = use(ChatMarkdownRendererContext);3116 } = use(ChatMarkdownRendererContext);
3108 const citation = href ? parseAssistantCitationHref(href) : null;3117 const citation = href ? parseAssistantCitationHref(href) : null;
3109 if (citation) return <AssistantCitationChip citation={citation} />;3118 if (citation) return <AssistantCitationChip citation={citation} />;
3119 // A thread link opens the thread here, never a browser.
3120 const threadLink = href ? parseThreadLinkHref(href) : null;
3121 if (threadLink) {
3122 return (
3123 <MarkdownThreadLink {...threadLink}>
3124 <MarkdownLinkContext value>{children}</MarkdownLinkContext>
3125 </MarkdownThreadLink>
3126 );
3127 }
3110 const contextReference = href ? parseComposerContextHref(href) : null;3128 const contextReference = href ? parseComposerContextHref(href) : null;
3111 if (contextReference) {3129 if (contextReference) {
3112 const label = hastPlainTextDeep(node) || contextReference.contextId;3130 const label = hastPlainTextDeep(node) || contextReference.contextId;
apps/web/src/components/chat/MarkdownThreadLink.tsx created+43
...@@ -0,0 +1,43 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { Link } from "@tanstack/react-router";
4import { MessageSquareTextIcon } from "lucide-react";
5import type { ReactNode } from "react";
6
7import { useProject, useThreadShell } from "../../state/entities";
8import { ProjectFavicon } from "../ProjectFavicon";
9
10/**
11 * A `t3-thread://` link in chat. It leads with the thread's project icon, like
12 * a web link leads with its favicon, so it reads as a thread link before you
13 * hover it. Opens the thread in the app.
14 */
15export function MarkdownThreadLink(props: {
16 readonly environmentId: EnvironmentId;
17 readonly threadId: ThreadId;
18 readonly children: ReactNode;
19}) {
20 const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId));
21 const project = useProject(
22 thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId),
23 );
24 return (
25 <Link
26 to="/$environmentId/$threadId"
27 params={{ environmentId: props.environmentId, threadId: props.threadId }}
28 title={project?.title}
29 >
30 <span
31 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
32 aria-hidden
33 >
34 {project === null ? (
35 <MessageSquareTextIcon className="block size-full shrink-0" />
36 ) : (
37 <ProjectFavicon project={project} className="size-full" />
38 )}
39 </span>
40 {props.children}
41 </Link>
42 );
43}
docs/orchestration-v2/orchestrator-mcp-server.md+7
...@@ -352,6 +352,13 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`....@@ -352,6 +352,13 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`.
352Actor and ingress are separate so agent-authored user-role messages remain352Actor and ingress are separate so agent-authored user-role messages remain
353distinguishable from human-authored messages.353distinguishable from human-authored messages.
354354
355List, read, and launch results include `link`, a Markdown link of the form
356`[title](t3-thread://v1/<environmentId>/<threadId>)` that clients open as the
357thread. List and read results also report `snoozed` and `snoozedUntil`, and
358`t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the
359client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread
360wakes early when it has a pending request, fails, or completes after the snooze.
361
355### `t3_thread_update`362### `t3_thread_update`
356363
357Updates metadata for the calling thread or any other thread in the environment.364Updates metadata for the calling thread or any other thread in the environment.
packages/contracts/src/orchestratorMcp.ts+11
...@@ -295,6 +295,7 @@ export const OrchestratorMcpThreadListInput = Schema.Struct({...@@ -295,6 +295,7 @@ export const OrchestratorMcpThreadListInput = Schema.Struct({
295 ),295 ),
296 titleContains: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(256))),296 titleContains: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(256))),
297 settled: Schema.optional(Schema.Boolean),297 settled: Schema.optional(Schema.Boolean),
298 snoozed: Schema.optional(Schema.Boolean),
298 includeSubagents: Schema.optional(Schema.Boolean),299 includeSubagents: Schema.optional(Schema.Boolean),
299 cursor: Schema.optional(NonNegativeInt),300 cursor: Schema.optional(NonNegativeInt),
300 limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))),301 limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))),
...@@ -303,6 +304,8 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp...@@ -303,6 +304,8 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp
303304
304export const OrchestratorMcpThreadListItem = Schema.Struct({305export const OrchestratorMcpThreadListItem = Schema.Struct({
305 threadId: ThreadId,306 threadId: ThreadId,
307 /** Paste this whenever you mention the thread, so the user can click to open it. */
308 link: Schema.String,
306 title: Schema.String,309 title: Schema.String,
307 createdBy: OrchestrationV2Actor,310 createdBy: OrchestrationV2Actor,
308 creationSource: OrchestrationV2CreationSource,311 creationSource: OrchestrationV2CreationSource,
...@@ -315,6 +318,9 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({...@@ -315,6 +318,9 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({
315 linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest),318 linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest),
316 settled: Schema.Boolean,319 settled: Schema.Boolean,
317 settledAt: Schema.NullOr(IsoDateTime),320 settledAt: Schema.NullOr(IsoDateTime),
321 snoozed: Schema.Boolean,
322 /** When a snoozed thread wakes; null when it is not snoozed. */
323 snoozedUntil: Schema.NullOr(IsoDateTime),
318 parentThreadId: Schema.NullOr(ThreadId),324 parentThreadId: Schema.NullOr(ThreadId),
319 relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])),325 relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])),
320 itemCount: NonNegativeInt,326 itemCount: NonNegativeInt,
...@@ -347,6 +353,8 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp...@@ -347,6 +353,8 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp
347353
348export const OrchestratorMcpThreadDetail = Schema.Struct({354export const OrchestratorMcpThreadDetail = Schema.Struct({
349 threadId: ThreadId,355 threadId: ThreadId,
356 /** Paste this whenever you mention the thread, so the user can click to open it. */
357 link: Schema.String,
350 projectId: ProjectId,358 projectId: ProjectId,
351 title: Schema.String,359 title: Schema.String,
352 createdBy: OrchestrationV2Actor,360 createdBy: OrchestrationV2Actor,
...@@ -370,6 +378,9 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({...@@ -370,6 +378,9 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({
370 archived: Schema.Boolean,378 archived: Schema.Boolean,
371 settled: Schema.Boolean,379 settled: Schema.Boolean,
372 settledAt: Schema.NullOr(IsoDateTime),380 settledAt: Schema.NullOr(IsoDateTime),
381 snoozed: Schema.Boolean,
382 /** When a snoozed thread wakes; null when it is not snoozed. */
383 snoozedUntil: Schema.NullOr(IsoDateTime),
373 createdAt: IsoDateTime,384 createdAt: IsoDateTime,
374 updatedAt: IsoDateTime,385 updatedAt: IsoDateTime,
375});386});
packages/shared/package.json+4
...@@ -243,6 +243,10 @@...@@ -243,6 +243,10 @@
243 "types": "./src/assistantCitations.ts",243 "types": "./src/assistantCitations.ts",
244 "import": "./src/assistantCitations.ts"244 "import": "./src/assistantCitations.ts"
245 },245 },
246 "./threadLinks": {
247 "types": "./src/threadLinks.ts",
248 "import": "./src/threadLinks.ts"
249 },
246 "./composerContextReferences": {250 "./composerContextReferences": {
247 "types": "./src/composerContextReferences.ts",251 "types": "./src/composerContextReferences.ts",
248 "import": "./src/composerContextReferences.ts"252 "import": "./src/composerContextReferences.ts"
packages/shared/src/threadLinks.test.ts created+28
...@@ -0,0 +1,28 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts";
4
5describe("thread links", () => {
6 it("round-trips ids that contain URL characters", () => {
7 const link = formatThreadLink({
8 environmentId: "studio mac",
9 threadId: "mcp:(1)/2",
10 title: "Fix [the] build\nnow",
11 });
12 const href = /\]\((.+)\)$/.exec(link)![1]!;
13 expect(link.startsWith("[Fix the build now](")).toBe(true);
14 // A raw parenthesis would end the Markdown link early.
15 expect(href).not.toMatch(/[()]/);
16 expect(parseThreadLinkHref(href)).toEqual({
17 environmentId: "studio mac",
18 threadId: "mcp:(1)/2",
19 });
20 });
21
22 it("rejects other links and malformed ones", () => {
23 expect(parseThreadLinkHref("https://t3.codes")).toBeNull();
24 expect(parseThreadLinkHref("t3-thread://v1/only-one")).toBeNull();
25 expect(parseThreadLinkHref("t3-thread://v1/env/%E0%A4%A")).toBeNull();
26 expect(parseThreadLinkHref("t3-thread://v1/%20/thread")).toBeNull();
27 });
28});
packages/shared/src/threadLinks.ts created+58
...@@ -0,0 +1,58 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5/**
6 * In-app links to threads. Agents put them in Markdown as
7 * `[title](t3-thread://v1/<environmentId>/<threadId>)`, and clients open the
8 * thread instead of a browser. Thread tools return a ready `link` for each
9 * thread, so agents never build one by hand.
10 */
11export const THREAD_LINK_PROTOCOL = "t3-thread";
12const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`;
13const LINK_LABEL_MAX_CHARS = 120;
14
15const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId);
16const decodeThreadId = Schema.decodeUnknownOption(ThreadId);
17
18// encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early.
19function encodeIdSegment(id: string): string {
20 return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29");
21}
22
23function formatThreadLinkHref(environmentId: string, threadId: string): string {
24 return `${THREAD_LINK_HREF_PREFIX}${encodeIdSegment(environmentId)}/${encodeIdSegment(threadId)}`;
25}
26
27/** A Markdown link to the thread, labeled with its title. */
28export function formatThreadLink(thread: {
29 readonly environmentId: string;
30 readonly threadId: string;
31 readonly title: string;
32}): string {
33 const label =
34 thread.title
35 .replace(/[[\]\\\r\n]/g, " ")
36 .replace(/\s+/g, " ")
37 .trim()
38 .slice(0, LINK_LABEL_MAX_CHARS) || "Untitled thread";
39 return `[${label}](${formatThreadLinkHref(thread.environmentId, thread.threadId)})`;
40}
41
42export function parseThreadLinkHref(
43 href: string,
44): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null {
45 if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null;
46 const parts = href.slice(THREAD_LINK_HREF_PREFIX.length).split("/");
47 if (parts.length !== 2) return null;
48 try {
49 const environmentId = decodeEnvironmentId(decodeURIComponent(parts[0]!));
50 const threadId = decodeThreadId(decodeURIComponent(parts[1]!));
51 return Option.isSome(environmentId) && Option.isSome(threadId)
52 ? { environmentId: environmentId.value, threadId: threadId.value }
53 : null;
54 } catch {
55 // Malformed percent encoding.
56 return null;
57 }
58}