| author | |
| committer | |
| log | 611132c171f3a821bd2e32f22261135cef6330ac |
| tree | 1610c8c6e8ac33556291a95f04472d972b948ee6 |
| parent | cd41c4ada0c70cc2eec95ecd7266f3dab010c58c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 5 | normalizeMarkdownLinkDestination, |
| 6 | 6 | parseMarkdownFileLink, |
| 7 | 7 | } from "@t3tools/client-runtime/markdown-links"; |
| 8 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; | |
| 8 | 9 | import { videoMimeType } from "@t3tools/shared/video"; |
| 9 | 10 | |
| 10 | 11 | import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; |
| ... | ... | @@ -293,7 +294,11 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese |
| 293 | 294 | |
| 294 | 295 | return { |
| 295 | 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 | } |
| 299 | 304 |
apps/mobile/src/features/threads/ThreadFeed.tsx+18-5| ... | ... | @@ -25,6 +25,7 @@ import { |
| 25 | 25 | } from "@t3tools/contracts"; |
| 26 | 26 | import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; |
| 27 | 27 | import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; |
| 28 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; | |
| 28 | 29 | import { |
| 29 | 30 | parseComposerContextHref, |
| 30 | 31 | collectComposerContextReferences, |
| ... | ... | @@ -1242,16 +1243,20 @@ function useMarkdownStyles( |
| 1242 | 1243 | ); |
| 1243 | 1244 | } |
| 1244 | 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 | 1248 | return ( |
| 1246 | 1249 | <MarkdownLinkLabelContext.Provider value="other"> |
| 1247 | 1250 | <NativeText |
| 1248 | 1251 | className="underline" |
| 1249 | 1252 | onPress={ |
| 1250 | linkHref | |
| 1251 | ? () => { | |
| 1252 | void tryOpenExternalUrl(linkHref, "markdown-link"); | |
| 1253 | } | |
| 1254 | : undefined | |
| 1253 | isThreadLink | |
| 1254 | ? () => onLinkPress(href) | |
| 1255 | : linkHref | |
| 1256 | ? () => { | |
| 1257 | void tryOpenExternalUrl(linkHref, "markdown-link"); | |
| 1258 | } | |
| 1259 | : undefined | |
| 1255 | 1260 | } |
| 1256 | 1261 | style={{ color: markdownLinkColor }} |
| 1257 | 1262 | > |
| ... | ... | @@ -2271,6 +2276,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2271 | 2276 | const userBubbleColor = theme["--color-user-bubble"]; |
| 2272 | 2277 | const onMarkdownLinkPress = useCallback( |
| 2273 | 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 | 2287 | const presentation = resolveMarkdownLinkPresentation(href); |
| 2275 | 2288 | if (presentation.kind === "file") { |
| 2276 | 2289 | const relativePath = resolveWorkspaceRelativeFilePath( |
apps/mobile/src/lib/nativeMarkdownText.test.ts+15| ... | ... | @@ -84,6 +84,21 @@ describe("nativeMarkdownTextRuns", () => { |
| 84 | 84 | ]); |
| 85 | 85 | }); |
| 86 | 86 | |
| 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 | 102 | it("preserves the destination of a link with a code-formatted label", () => { |
| 88 | 103 | expect( |
| 89 | 104 | nativeMarkdownTextRuns({ |
apps/server/src/mcp/OrchestratorMcpService.ts+54-5| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | type EnvironmentId, | |
| 2 | 3 | CommandId, |
| 3 | 4 | type RunId, |
| 4 | 5 | isProviderAvailable, |
| ... | ... | @@ -58,6 +59,8 @@ import { |
| 58 | 59 | ThreadId, |
| 59 | 60 | } from "@t3tools/contracts"; |
| 60 | 61 | import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; |
| 62 | import { formatThreadLink } from "@t3tools/shared/threadLinks"; | |
| 63 | import * as Clock from "effect/Clock"; | |
| 61 | 64 | import * as Context from "effect/Context"; |
| 62 | 65 | import * as Crypto from "effect/Crypto"; |
| 63 | 66 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -80,6 +83,7 @@ import { |
| 80 | 83 | type DispatchModeRefusal, |
| 81 | 84 | } from "../orchestration-v2/DispatchModeLimit.ts"; |
| 82 | 85 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 86 | import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; | |
| 83 | 87 | import * as ProjectService from "../project/ProjectService.ts"; |
| 84 | 88 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 85 | 89 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| ... | ... | @@ -617,9 +621,35 @@ function threadSettlement( |
| 617 | 621 | }; |
| 618 | 622 | } |
| 619 | 623 | |
| 620 | function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpThreadListItem { | |
| 624 | function 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. */ | |
| 637 | interface ThreadViewContext { | |
| 638 | readonly environmentId: EnvironmentId; | |
| 639 | readonly nowMs: number; | |
| 640 | } | |
| 641 | ||
| 642 | function listItemFromShell( | |
| 643 | shell: OrchestrationV2ThreadShell, | |
| 644 | context: ThreadViewContext, | |
| 645 | ): OrchestratorMcpThreadListItem { | |
| 621 | 646 | return { |
| 622 | 647 | threadId: shell.id, |
| 648 | link: formatThreadLink({ | |
| 649 | environmentId: context.environmentId, | |
| 650 | threadId: shell.id, | |
| 651 | title: shell.title, | |
| 652 | }), | |
| 623 | 653 | title: shell.title, |
| 624 | 654 | createdBy: shell.createdBy, |
| 625 | 655 | creationSource: shell.creationSource, |
| ... | ... | @@ -631,6 +661,7 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh |
| 631 | 661 | interactionMode: shell.interactionMode, |
| 632 | 662 | linkedPullRequest: shell.linkedPullRequest ?? null, |
| 633 | 663 | ...threadSettlement(shell), |
| 664 | ...threadSnooze(shell, context.nowMs), | |
| 634 | 665 | parentThreadId: shell.lineage.parentThreadId, |
| 635 | 666 | relationshipToParent: shell.lineage.relationshipToParent, |
| 636 | 667 | itemCount: shell.visibleItemCount, |
| ... | ... | @@ -642,11 +673,18 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh |
| 642 | 673 | function threadDetail( |
| 643 | 674 | projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">, |
| 644 | 675 | itemCount: number, |
| 676 | shell: OrchestrationV2ThreadShell, | |
| 677 | context: ThreadViewContext, | |
| 645 | 678 | ): OrchestratorMcpThreadDetail { |
| 646 | 679 | const latest = ThreadManagementService.latestRun(projection); |
| 647 | 680 | const active = ThreadManagementService.latestActiveRun(projection); |
| 648 | 681 | return { |
| 649 | 682 | threadId: projection.thread.id, |
| 683 | link: formatThreadLink({ | |
| 684 | environmentId: context.environmentId, | |
| 685 | threadId: projection.thread.id, | |
| 686 | title: projection.thread.title, | |
| 687 | }), | |
| 650 | 688 | projectId: projection.thread.projectId, |
| 651 | 689 | title: projection.thread.title, |
| 652 | 690 | createdBy: projection.thread.createdBy, |
| ... | ... | @@ -678,6 +716,8 @@ function threadDetail( |
| 678 | 716 | ).length, |
| 679 | 717 | archived: projection.thread.archivedAt !== null, |
| 680 | 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 | 721 | createdAt: DateTime.formatIso(projection.thread.createdAt), |
| 682 | 722 | updatedAt: DateTime.formatIso(projection.thread.updatedAt), |
| 683 | 723 | }; |
| ... | ... | @@ -1024,7 +1064,7 @@ const make = Effect.gen(function* () { |
| 1024 | 1064 | "contextTransfers", |
| 1025 | 1065 | ]) |
| 1026 | 1066 | .pipe(Effect.mapError(threadManagementFailure)); |
| 1027 | return { parent, target } as const; | |
| 1067 | return { parent, target, shell } as const; | |
| 1028 | 1068 | }); |
| 1029 | 1069 | |
| 1030 | 1070 | const loadProviders = providerRegistry.getProviders; |
| ... | ... | @@ -2226,6 +2266,7 @@ const make = Effect.gen(function* () { |
| 2226 | 2266 | failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`), |
| 2227 | 2267 | ), |
| 2228 | 2268 | ); |
| 2269 | const nowMs = yield* Clock.currentTimeMillis; | |
| 2229 | 2270 | const statuses = input.statuses === undefined ? null : new Set(input.statuses); |
| 2230 | 2271 | const titleContains = input.titleContains?.toLocaleLowerCase(); |
| 2231 | 2272 | const filtered = projectThreads |
| ... | ... | @@ -2237,6 +2278,9 @@ const make = Effect.gen(function* () { |
| 2237 | 2278 | (thread) => |
| 2238 | 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 | 2284 | .filter( |
| 2241 | 2285 | (thread) => |
| 2242 | 2286 | titleContains === undefined || |
| ... | ... | @@ -2249,14 +2293,16 @@ const make = Effect.gen(function* () { |
| 2249 | 2293 | return { |
| 2250 | 2294 | projectId, |
| 2251 | 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 | 2299 | nextCursor, |
| 2254 | 2300 | total: filtered.length, |
| 2255 | 2301 | } satisfies OrchestratorMcpThreadListResult; |
| 2256 | 2302 | }), |
| 2257 | 2303 | readThread: (scope, input) => |
| 2258 | 2304 | Effect.gen(function* () { |
| 2259 | const { parent, target } = yield* loadReadableThread(scope, input.threadId); | |
| 2305 | const { parent, target, shell } = yield* loadReadableThread(scope, input.threadId); | |
| 2260 | 2306 | const view = input.view ?? "messages"; |
| 2261 | 2307 | const afterPosition = input.afterPosition ?? -1; |
| 2262 | 2308 | const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT; |
| ... | ... | @@ -2329,7 +2375,10 @@ const make = Effect.gen(function* () { |
| 2329 | 2375 | } |
| 2330 | 2376 | } |
| 2331 | 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 | 2382 | recentRuns: target.runs |
| 2334 | 2383 | .toSorted((left, right) => right.ordinal - left.ordinal) |
| 2335 | 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 | 2232 | reason: "user", |
| 2233 | 2233 | }); |
| 2234 | 2234 | |
| 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 | 2273 | const unlinkedCall = yield* invoke("t3_thread_update", { |
| 2236 | 2274 | threadId: emptyThread.threadId, |
| 2237 | 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 | 178 | |
| 179 | 179 | const ThreadListTool = Tool.make("t3_thread_list", { |
| 180 | 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 | 182 | parameters: OrchestratorMcpThreadListInput, |
| 183 | 183 | success: OrchestratorMcpThreadListResult, |
| 184 | 184 | failure: OrchestratorMcpFailure, |
| ... | ... | @@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { |
| 192 | 192 | |
| 193 | 193 | const ThreadReadTool = Tool.make("t3_thread_read", { |
| 194 | 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 | 196 | parameters: OrchestratorMcpThreadReadInput, |
| 197 | 197 | success: OrchestratorMcpThreadReadResult, |
| 198 | 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 | 69 | return Effect.succeed({ |
| 70 | 70 | threadId: input.threadId, |
| 71 | 71 | projection: { |
| 72 | thread: { id: input.threadId, projectId, modelSelection }, | |
| 72 | thread: { id: input.threadId, projectId, modelSelection, title: input.title }, | |
| 73 | 73 | runs: [], |
| 74 | 74 | }, |
| 75 | 75 | resumed: false, |
| ... | ... | @@ -94,7 +94,11 @@ it.effect("attributes a launched thread's first message to the calling thread", |
| 94 | 94 | const result = yield* toolkit |
| 95 | 95 | .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) |
| 96 | 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 | 102 | expect(launchedSender).toBe(sourceThreadId); |
| 99 | 103 | }), |
| 100 | 104 | ); |
| ... | ... | @@ -140,7 +144,12 @@ it.effect("launches a scratch thread into the Scratch project", () => |
| 140 | 144 | return Effect.succeed({ |
| 141 | 145 | threadId: input.threadId, |
| 142 | 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 | 153 | runs: [], |
| 145 | 154 | }, |
| 146 | 155 | resumed: false, |
| ... | ... | @@ -329,6 +338,7 @@ const clientLaunchHarness = (input: { |
| 329 | 338 | id: launch.threadId, |
| 330 | 339 | projectId: launch.projectId, |
| 331 | 340 | modelSelection: launch.modelSelection, |
| 341 | title: launch.title, | |
| 332 | 342 | }, |
| 333 | 343 | runs: [], |
| 334 | 344 | }, |
apps/server/src/mcp/toolkits/project/handlers.ts+7-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; |
| 2 | import { formatThreadLink } from "@t3tools/shared/threadLinks"; | |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 5 | import * as Option from "effect/Option"; |
| ... | ... | @@ -57,7 +58,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { |
| 57 | 58 | (input, { runtimeMode, interactionMode }) => |
| 58 | 59 | Effect.gen(function* () { |
| 59 | 60 | const context = yield* readCaller(); |
| 60 | const { caller } = context; | |
| 61 | const { caller, scope } = context; | |
| 61 | 62 | const commandId = yield* newCommandId(); |
| 62 | 63 | const threadId = ThreadId.make(commandId); |
| 63 | 64 | const messageId = MessageId.make(commandId); |
| ... | ... | @@ -146,6 +147,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { |
| 146 | 147 | const run = result.projection.runs.find((run) => run.userMessageId === messageId); |
| 147 | 148 | return { |
| 148 | 149 | threadId: thread.id, |
| 150 | link: formatThreadLink({ | |
| 151 | environmentId: scope.environmentId, | |
| 152 | threadId: thread.id, | |
| 153 | title: thread.title, | |
| 154 | }), | |
| 149 | 155 | projectId: thread.projectId, |
| 150 | 156 | modelSelection: thread.modelSelection, |
| 151 | 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 | 103 | const ThreadLaunchTool = Tool.make("t3_thread_launch", { |
| 104 | 104 | ...shared, |
| 105 | 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 | 107 | parameters: Schema.Struct({ |
| 108 | 108 | projectId: Schema.optional(ProjectId), |
| 109 | 109 | scratch: Schema.optional( |
| ... | ... | @@ -132,6 +132,8 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { |
| 132 | 132 | }), |
| 133 | 133 | success: Schema.Struct({ |
| 134 | 134 | threadId: ThreadId, |
| 135 | /** Paste this whenever you mention the thread, so the user can click to open it. */ | |
| 136 | link: Schema.String, | |
| 135 | 137 | projectId: ProjectId, |
| 136 | 138 | modelSelection: ModelSelection, |
| 137 | 139 | runId: Schema.NullOr(RunId), |
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+22| ... | ... | @@ -1252,3 +1252,25 @@ describe("ThreadSettlementServiceV2 single-thread sweeps", () => { |
| 1252 | 1252 | ), |
| 1253 | 1253 | ); |
| 1254 | 1254 | }); |
| 1255 | ||
| 1256 | describe("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 | 163 | return wokeOnError || wokeOnCompletion; |
| 164 | 164 | } |
| 165 | 165 | |
| 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 | */ | |
| 175 | export 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 | ||
| 166 | 199 | export function resolveAutoSettlementAt(input: { |
| 167 | 200 | readonly thread: ProjectionStore.ProjectionSettlementCandidate; |
| 168 | 201 | readonly pullRequest: SettlementPullRequest | null; |
apps/web/src/components/ChatMarkdown.tsx+19-1| ... | ... | @@ -88,7 +88,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; |
| 88 | 88 | import remarkBreaks from "remark-breaks"; |
| 89 | 89 | import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; |
| 90 | 90 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; |
| 91 | import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks"; | |
| 91 | 92 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; |
| 93 | import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; | |
| 92 | 94 | import remarkGfm from "remark-gfm"; |
| 93 | 95 | import type { Processor } from "unified"; |
| 94 | 96 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| ... | ... | @@ -538,7 +540,13 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 538 | 540 | }, |
| 539 | 541 | protocols: { |
| 540 | 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 | 550 | src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"], |
| 543 | 551 | }, |
| 544 | 552 | } satisfies Parameters<typeof rehypeSanitize>[0]; |
| ... | ... | @@ -2645,6 +2653,7 @@ function useChatMarkdownState({ |
| 2645 | 2653 | const markdownUrlTransform = useCallback((href: string) => { |
| 2646 | 2654 | if (parseAssistantCitationHref(href)) return href; |
| 2647 | 2655 | if (parseComposerContextHref(href)) return href; |
| 2656 | if (parseThreadLinkHref(href)) return href; | |
| 2648 | 2657 | if (isWindowsDrivePathHref(href)) return href; |
| 2649 | 2658 | return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); |
| 2650 | 2659 | }, []); |
| ... | ... | @@ -3107,6 +3116,15 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3107 | 3116 | } = use(ChatMarkdownRendererContext); |
| 3108 | 3117 | const citation = href ? parseAssistantCitationHref(href) : null; |
| 3109 | 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 | 3128 | const contextReference = href ? parseComposerContextHref(href) : null; |
| 3111 | 3129 | if (contextReference) { |
| 3112 | 3130 | const label = hastPlainTextDeep(node) || contextReference.contextId; |
apps/web/src/components/chat/MarkdownThreadLink.tsx created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { Link } from "@tanstack/react-router"; | |
| 4 | import { MessageSquareTextIcon } from "lucide-react"; | |
| 5 | import type { ReactNode } from "react"; | |
| 6 | ||
| 7 | import { useProject, useThreadShell } from "../../state/entities"; | |
| 8 | import { 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 | */ | |
| 15 | export 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 | 352 | Actor and ingress are separate so agent-authored user-role messages remain |
| 353 | 353 | distinguishable from human-authored messages. |
| 354 | 354 | |
| 355 | List, read, and launch results include `link`, a Markdown link of the form | |
| 356 | `[title](t3-thread://v1/<environmentId>/<threadId>)` that clients open as the | |
| 357 | thread. List and read results also report `snoozed` and `snoozedUntil`, and | |
| 358 | `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the | |
| 359 | client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread | |
| 360 | wakes early when it has a pending request, fails, or completes after the snooze. | |
| 361 | ||
| 355 | 362 | ### `t3_thread_update` |
| 356 | 363 | |
| 357 | 364 | Updates 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 | 295 | ), |
| 296 | 296 | titleContains: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(256))), |
| 297 | 297 | settled: Schema.optional(Schema.Boolean), |
| 298 | snoozed: Schema.optional(Schema.Boolean), | |
| 298 | 299 | includeSubagents: Schema.optional(Schema.Boolean), |
| 299 | 300 | cursor: Schema.optional(NonNegativeInt), |
| 300 | 301 | limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), |
| ... | ... | @@ -303,6 +304,8 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp |
| 303 | 304 | |
| 304 | 305 | export const OrchestratorMcpThreadListItem = Schema.Struct({ |
| 305 | 306 | threadId: ThreadId, |
| 307 | /** Paste this whenever you mention the thread, so the user can click to open it. */ | |
| 308 | link: Schema.String, | |
| 306 | 309 | title: Schema.String, |
| 307 | 310 | createdBy: OrchestrationV2Actor, |
| 308 | 311 | creationSource: OrchestrationV2CreationSource, |
| ... | ... | @@ -315,6 +318,9 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ |
| 315 | 318 | linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), |
| 316 | 319 | settled: Schema.Boolean, |
| 317 | 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 | 324 | parentThreadId: Schema.NullOr(ThreadId), |
| 319 | 325 | relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])), |
| 320 | 326 | itemCount: NonNegativeInt, |
| ... | ... | @@ -347,6 +353,8 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp |
| 347 | 353 | |
| 348 | 354 | export const OrchestratorMcpThreadDetail = Schema.Struct({ |
| 349 | 355 | threadId: ThreadId, |
| 356 | /** Paste this whenever you mention the thread, so the user can click to open it. */ | |
| 357 | link: Schema.String, | |
| 350 | 358 | projectId: ProjectId, |
| 351 | 359 | title: Schema.String, |
| 352 | 360 | createdBy: OrchestrationV2Actor, |
| ... | ... | @@ -370,6 +378,9 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ |
| 370 | 378 | archived: Schema.Boolean, |
| 371 | 379 | settled: Schema.Boolean, |
| 372 | 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 | 384 | createdAt: IsoDateTime, |
| 374 | 385 | updatedAt: IsoDateTime, |
| 375 | 386 | }); |
packages/shared/package.json+4| ... | ... | @@ -243,6 +243,10 @@ |
| 243 | 243 | "types": "./src/assistantCitations.ts", |
| 244 | 244 | "import": "./src/assistantCitations.ts" |
| 245 | 245 | }, |
| 246 | "./threadLinks": { | |
| 247 | "types": "./src/threadLinks.ts", | |
| 248 | "import": "./src/threadLinks.ts" | |
| 249 | }, | |
| 246 | 250 | "./composerContextReferences": { |
| 247 | 251 | "types": "./src/composerContextReferences.ts", |
| 248 | 252 | "import": "./src/composerContextReferences.ts" |
packages/shared/src/threadLinks.test.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import * 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 | */ | |
| 11 | export const THREAD_LINK_PROTOCOL = "t3-thread"; | |
| 12 | const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; | |
| 13 | const LINK_LABEL_MAX_CHARS = 120; | |
| 14 | ||
| 15 | const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId); | |
| 16 | const decodeThreadId = Schema.decodeUnknownOption(ThreadId); | |
| 17 | ||
| 18 | // encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early. | |
| 19 | function encodeIdSegment(id: string): string { | |
| 20 | return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29"); | |
| 21 | } | |
| 22 | ||
| 23 | function 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. */ | |
| 28 | export 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 | ||
| 42 | export 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 | } |