From 947d1b809183052101c26c882d3f70908b7b3a21 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:39:00 -0700 Subject: [PATCH 1/9] fix(server,web,mobile): thread links reference the thread id, not a baked title #16782 had thread tools return a ready `link` with the title and environment id baked into it. The label went stale on rename, and the extra environment segment was redundant: a message's thread link always names a thread in the message's own environment. Thread links are now `[title](t3-thread://v1/)`. The id is the only reference. Web renders them as the composer's thread chip, which reads the live title from the thread shell. Mobile relabels them from the live thread index. The `link` fields are gone from t3_thread_list, t3_thread_read and t3_thread_launch, and the orchestration instructions teach the id-only form instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/threads/ThreadFeed.tsx | 30 +++++--- .../mobile/src/lib/nativeMarkdownText.test.ts | 4 +- apps/mobile/src/state/entities.ts | 36 ++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 35 ++-------- ...OrchestratorMcpToolkit.integration.test.ts | 3 - .../src/mcp/toolkits/orchestrator/tools.ts | 4 +- .../src/mcp/toolkits/project/handlers.test.ts | 16 +---- .../src/mcp/toolkits/project/handlers.ts | 8 +-- apps/server/src/mcp/toolkits/project/tools.ts | 4 +- .../provider/T3OrchestrationInstructions.ts | 1 + apps/web/src/components/ChatMarkdown.tsx | 20 +++--- .../components/chat/MarkdownThreadLink.tsx | 43 ------------ .../orchestrator-mcp-server.md | 9 ++- packages/contracts/src/orchestratorMcp.ts | 4 -- packages/shared/src/threadLinks.test.ts | 38 +++++------ packages/shared/src/threadLinks.ts | 68 ++++++++----------- 16 files changed, 136 insertions(+), 187 deletions(-) delete mode 100644 apps/web/src/components/chat/MarkdownThreadLink.tsx diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b5f258a0889f..b2e10812cb5e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -198,6 +198,7 @@ import { } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; +import { useLiveThreadLinkLabels } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; import * as Option from "effect/Option"; @@ -906,16 +907,15 @@ interface MarkdownLinkHandlers { const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; + readonly environmentId: EnvironmentId; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; }) { - const segments = useMemo( - () => splitCodexArtifactTemplateMarkdown(props.markdown), - [props.markdown], - ); + const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); + const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]); return segments.map((segment) => { if (segment.kind === "artifact-template") { @@ -1905,6 +1905,7 @@ function renderFeedEntry( (null); const navigation = useNavigation(); const { selectedThread } = useThreadSelection(); - const text = replaceComposerContextReferences(props.text, (ref) => { + const liveText = useLiveThreadLinkLabels(props.text, props.environmentId); + const text = replaceComposerContextReferences(liveText, (ref) => { const available = props.context?.records.some((record) => record.contextId === ref.contextId); return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`; }); @@ -2292,11 +2294,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { - const threadLink = parseThreadLinkHref(href); - if (threadLink) { + // A thread link names a thread in this feed's environment. + const linkedThreadId = parseThreadLinkHref(href); + if (linkedThreadId) { navigation.navigate("Thread", { - environmentId: String(threadLink.environmentId), - threadId: String(threadLink.threadId), + environmentId: String(props.environmentId), + threadId: String(linkedThreadId), }); return; } @@ -2504,13 +2507,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (text: string) => ( ), - [markdownStyles.assistant, markdownLinkHandlers, renderMarkdownImage, props.skills], + [ + markdownStyles.assistant, + markdownLinkHandlers, + renderMarkdownImage, + props.skills, + props.environmentId, + ], ); const reviewCommentColors = useReviewCommentColors(); const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null; diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 62f0de0c1b95..e43685407f85 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -91,12 +91,12 @@ describe("nativeMarkdownTextRuns", () => { children: [ { type: "link", - href: "t3-thread://v1/env/thread-1", + href: "t3-thread://v1/thread-1", children: [{ type: "text", content: "Fix the build" }], }, ], }), - ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]); + ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/thread-1" }]); }); it("preserves the destination of a link with a code-formatted label", () => { diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 94ab662c7291..0d3b33d582d5 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -12,8 +12,11 @@ import type { ScopedProjectRef, ScopedThreadRef, ServerConfig, + ThreadId, } from "@t3tools/contracts"; +import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks"; import { Atom } from "effect/reactivity"; +import { useMemo } from "react"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom, serverEnvironment } from "./server"; @@ -28,6 +31,25 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).p const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-server-config:empty"), ); +const EMPTY_THREAD_TITLES: ReadonlyMap = new Map(); +const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe( + Atom.withLabel("mobile-thread-titles:empty"), +); + +/** Thread titles in one environment. Emits when a title changes, not on every shell update. */ +const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => { + let previous = EMPTY_THREAD_TITLES; + return Atom.make((get) => { + const index = get(environmentThreadShells.environmentThreadIndexAtom(environmentId)); + const unchanged = + index.size === previous.size && + Array.from(index).every(([threadId, shell]) => previous.get(threadId) === shell.title); + if (!unchanged) { + previous = new Map(Array.from(index, ([threadId, shell]) => [threadId, shell.title])); + } + return previous; + }).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`)); +}); /** Resolves when the project event reaches the live client store. */ export function waitForProject( @@ -76,6 +98,20 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ +export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { + const titles = useAtomValue( + hasThreadLinks(markdown) ? threadTitlesAtom(environmentId) : EMPTY_THREAD_TITLES_ATOM, + ); + return useMemo( + () => + titles.size === 0 + ? markdown + : relabelThreadLinks(markdown, (threadId) => titles.get(threadId)), + [markdown, titles], + ); +} + export function useEnvironmentServerConfig( environmentId: EnvironmentId | null, ): ServerConfig | null { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index dccda1b49519..ebb1aa9e4c1a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,5 +1,4 @@ import { - type EnvironmentId, CommandId, type RunId, isProviderAvailable, @@ -59,7 +58,6 @@ import { ThreadId, } from "@t3tools/contracts"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -633,23 +631,12 @@ function threadSnooze( }; } -/** Where and when a thread is shown: the environment for its link, and the time for snooze state. */ -interface ThreadViewContext { - readonly environmentId: EnvironmentId; - readonly nowMs: number; -} - function listItemFromShell( shell: OrchestrationV2ThreadShell, - context: ThreadViewContext, + nowMs: number, ): OrchestratorMcpThreadListItem { return { threadId: shell.id, - link: formatThreadLink({ - environmentId: context.environmentId, - threadId: shell.id, - title: shell.title, - }), title: shell.title, createdBy: shell.createdBy, creationSource: shell.creationSource, @@ -661,7 +648,7 @@ function listItemFromShell( interactionMode: shell.interactionMode, linkedPullRequest: shell.linkedPullRequest ?? null, ...threadSettlement(shell), - ...threadSnooze(shell, context.nowMs), + ...threadSnooze(shell, nowMs), parentThreadId: shell.lineage.parentThreadId, relationshipToParent: shell.lineage.relationshipToParent, itemCount: shell.visibleItemCount, @@ -674,17 +661,12 @@ function threadDetail( projection: Pick, itemCount: number, shell: OrchestrationV2ThreadShell, - context: ThreadViewContext, + nowMs: number, ): OrchestratorMcpThreadDetail { const latest = ThreadManagementService.latestRun(projection); const active = ThreadManagementService.latestActiveRun(projection); return { threadId: projection.thread.id, - link: formatThreadLink({ - environmentId: context.environmentId, - threadId: projection.thread.id, - title: projection.thread.title, - }), projectId: projection.thread.projectId, title: projection.thread.title, createdBy: projection.thread.createdBy, @@ -717,7 +699,7 @@ function threadDetail( archived: projection.thread.archivedAt !== null, ...threadSettlement(projection.thread), // From the shell, like the list, so read and list agree on snooze state. - ...threadSnooze(shell, context.nowMs), + ...threadSnooze(shell, nowMs), createdAt: DateTime.formatIso(projection.thread.createdAt), updatedAt: DateTime.formatIso(projection.thread.updatedAt), }; @@ -2293,9 +2275,7 @@ const make = Effect.gen(function* () { return { projectId, currentThreadId: parent?.thread.id ?? null, - threads: page.map((shell) => - listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }), - ), + threads: page.map((shell) => listItemFromShell(shell, nowMs)), nextCursor, total: filtered.length, } satisfies OrchestratorMcpThreadListResult; @@ -2375,10 +2355,7 @@ const make = Effect.gen(function* () { } } return { - thread: threadDetail(target, timeline.totalItems, shell, { - environmentId: scope.environmentId, - nowMs: yield* Clock.currentTimeMillis, - }), + thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis), recentRuns: target.runs .toSorted((left, right) => right.ordinal - left.ordinal) .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 673830dd66d3..e59f8fff9a05 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -2233,9 +2233,6 @@ describe("orchestrator MCP toolkit", () => { }); expect(metadataRead.thread).toMatchObject({ snoozed: false, snoozedUntil: null }); - expect(metadataRead.thread.link).toBe( - `[Metadata-managed thread](t3-thread://v1/environment%3Amcp-orchestrator/${encodeURIComponent(emptyThread.threadId)})`, - ); yield* orchestrator.dispatch({ type: "thread.snooze", commandId: CommandId.make("command:mcp-empty:snooze"), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 256b08638ac0..92e052f3e988 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "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.", + "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.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "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.", + "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 also reports its snooze state.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 255829ff094c..d657938ff206 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread", return Effect.succeed({ threadId: input.threadId, projection: { - thread: { id: input.threadId, projectId, modelSelection, title: input.title }, + thread: { id: input.threadId, projectId, modelSelection }, runs: [], }, resumed: false, @@ -94,11 +94,7 @@ it.effect("attributes a launched thread's first message to the calling thread", const result = yield* toolkit .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - expect(result.at(-1)?.result).toMatchObject({ - projectId, - modelSelection, - link: expect.stringMatching(/^\[Audit\]\(t3-thread:\/\/v1\/environment\//), - }); + expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); expect(launchedSender).toBe(sourceThreadId); }), ); @@ -144,12 +140,7 @@ it.effect("launches a scratch thread into the Scratch project", () => return Effect.succeed({ threadId: input.threadId, projection: { - thread: { - id: input.threadId, - projectId: input.projectId, - modelSelection, - title: input.title, - }, + thread: { id: input.threadId, projectId: input.projectId, modelSelection }, runs: [], }, resumed: false, @@ -338,7 +329,6 @@ const clientLaunchHarness = (input: { id: launch.threadId, projectId: launch.projectId, modelSelection: launch.modelSelection, - title: launch.title, }, runs: [], }, diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 4f3b66e8dc70..9a7d7024111e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,5 +1,4 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -58,7 +57,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { const context = yield* readCaller(); - const { caller, scope } = context; + const { caller } = context; const commandId = yield* newCommandId(); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); @@ -147,11 +146,6 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const run = result.projection.runs.find((run) => run.userMessageId === messageId); return { threadId: thread.id, - link: formatThreadLink({ - environmentId: scope.environmentId, - threadId: thread.id, - title: thread.title, - }), projectId: thread.projectId, modelSelection: thread.modelSelection, runId: run?.id ?? null, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index bb81b8c83e0e..ab04bd5a195b 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - '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:"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/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.', + '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:"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/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.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -132,8 +132,6 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), success: Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, projectId: ProjectId, modelSelection: ModelSelection, runId: Schema.NullOr(RunId), diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index fe700c0e2084..8f0eb5014064 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -11,6 +11,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. +- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`. T3 Code opens the thread in the app and shows its current title. ### Choose the workspace before starting a new thread diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e595efdc5a07..1340804ffaac 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -90,7 +90,7 @@ import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; -import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; +import { ThreadContextChip } from "./ThreadContextChip"; import remarkGfm from "remark-gfm"; import type { Processor } from "unified"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; @@ -3167,13 +3167,17 @@ const CHAT_MARKDOWN_COMPONENTS = { } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; - // A thread link opens the thread here, never a browser. - const threadLink = href ? parseThreadLinkHref(href) : null; - if (threadLink) { - return ( - - {children} - + // A thread link is the same chip as an attached thread: current title, opens in the app. + const linkedThreadId = href ? parseThreadLinkHref(href) : null; + if (linkedThreadId) { + const title = hastPlainTextDeep(node) || linkedThreadId; + return environmentId ? ( + + ) : ( + {title} ); } const contextReference = href ? parseComposerContextHref(href) : null; diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx deleted file mode 100644 index cc3f5bdb0efb..000000000000 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { Link } from "@tanstack/react-router"; -import { MessageSquareTextIcon } from "lucide-react"; -import type { ReactNode } from "react"; - -import { useProject, useThreadShell } from "../../state/entities"; -import { ProjectFavicon } from "../ProjectFavicon"; - -/** - * A `t3-thread://` link in chat. It leads with the thread's project icon, like - * a web link leads with its favicon, so it reads as a thread link before you - * hover it. Opens the thread in the app. - */ -export function MarkdownThreadLink(props: { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly children: ReactNode; -}) { - const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); - const project = useProject( - thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId), - ); - return ( - - - {project === null ? ( - - ) : ( - - )} - - {props.children} - - ); -} diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 08c2f19bdabf..5fc670a99c6d 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -352,9 +352,12 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`. Actor and ingress are separate so agent-authored user-role messages remain distinguishable from human-authored messages. -List, read, and launch results include `link`, a Markdown link of the form -`[title](t3-thread://v1//)` that clients open as the -thread. List and read results also report `snoozed` and `snoozedUntil`, and +Agents mention another thread as `[title](t3-thread://v1/)`. The +link carries only the id, which resolves in the environment of the message that +holds it. Clients show the thread's current title rather than the label, so a +rename never leaves a stale link. + +List and read results report `snoozed` and `snoozedUntil`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread wakes early when it has a pending request, fails, or completes after the snooze. diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d13cf419f19f..0ab3e0b9b86c 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -304,8 +304,6 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, title: Schema.String, createdBy: OrchestrationV2Actor, creationSource: OrchestrationV2CreationSource, @@ -353,8 +351,6 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp export const OrchestratorMcpThreadDetail = Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, projectId: ProjectId, title: Schema.String, createdBy: OrchestrationV2Actor, diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index b6f853e9827f..eaa582c31635 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -1,28 +1,28 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts"; +import { parseThreadLinkHref, relabelThreadLinks } from "./threadLinks.ts"; describe("thread links", () => { - it("round-trips ids that contain URL characters", () => { - const link = formatThreadLink({ - environmentId: "studio mac", - threadId: "mcp:(1)/2", - title: "Fix [the] build\nnow", - }); - const href = /\]\((.+)\)$/.exec(link)![1]!; - expect(link.startsWith("[Fix the build now](")).toBe(true); - // A raw parenthesis would end the Markdown link early. - expect(href).not.toMatch(/[()]/); - expect(parseThreadLinkHref(href)).toEqual({ - environmentId: "studio mac", - threadId: "mcp:(1)/2", - }); + it("takes the thread id verbatim, percent escapes included", () => { + expect(parseThreadLinkHref("t3-thread://v1/mcp:1234")).toBe("mcp:1234"); + expect(parseThreadLinkHref("t3-thread://v1/thread:delegated-task:mcp%3A1")).toBe( + "thread:delegated-task:mcp%3A1", + ); }); - it("rejects other links and malformed ones", () => { + it("rejects other links and an empty id", () => { expect(parseThreadLinkHref("https://t3.codes")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/only-one")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/env/%E0%A4%A")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/%20/thread")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/ ")).toBeNull(); + }); + + it("relabels links with the current title and leaves unknown threads alone", () => { + const titles = new Map([["renamed", "Fix [the] build\nnow"]]); + expect( + relabelThreadLinks( + "See [Old name](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted).", + (threadId) => titles.get(threadId), + ), + ).toBe("See [Fix the build now](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted)."); }); }); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index 179350c1ee6b..ee027a00f74f 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -1,58 +1,44 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; /** - * In-app links to threads. Agents put them in Markdown as - * `[title](t3-thread://v1//)`, and clients open the - * thread instead of a browser. Thread tools return a ready `link` for each - * thread, so agents never build one by hand. + * Agents mention another thread in Markdown as `[title](t3-thread://v1/)`. The id is + * the reference and resolves in the environment of the message that holds it. Titles change, so + * clients show the thread's current title; the label only stands in for a thread they cannot see. */ export const THREAD_LINK_PROTOCOL = "t3-thread"; const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; -const LINK_LABEL_MAX_CHARS = 120; +const THREAD_LINK = /\[([^\]\n]*)\]\((t3-thread:\/\/v1\/[^\s)]+)\)/g; -const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId); const decodeThreadId = Schema.decodeUnknownOption(ThreadId); -// encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early. -function encodeIdSegment(id: string): string { - return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29"); +/** The id is taken verbatim: thread ids can hold percent escapes of their own. */ +export function parseThreadLinkHref(href: string): ThreadId | null { + if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; + return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length))); } -function formatThreadLinkHref(environmentId: string, threadId: string): string { - return `${THREAD_LINK_HREF_PREFIX}${encodeIdSegment(environmentId)}/${encodeIdSegment(threadId)}`; +export function hasThreadLinks(markdown: string): boolean { + return markdown.includes(`](${THREAD_LINK_HREF_PREFIX}`); } -/** A Markdown link to the thread, labeled with its title. */ -export function formatThreadLink(thread: { - readonly environmentId: string; - readonly threadId: string; - readonly title: string; -}): string { - const label = - thread.title - .replace(/[[\]\\\r\n]/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, LINK_LABEL_MAX_CHARS) || "Untitled thread"; - return `[${label}](${formatThreadLinkHref(thread.environmentId, thread.threadId)})`; +/** Relabels each thread link with `title(threadId)`; a link it returns nothing for keeps its label. */ +export function relabelThreadLinks( + markdown: string, + title: (threadId: ThreadId) => string | undefined, +): string { + if (!hasThreadLinks(markdown)) return markdown; + return markdown.replace(THREAD_LINK, (source, _label: string, href: string) => { + const threadId = parseThreadLinkHref(href); + const label = threadId === null ? "" : sanitizeLinkLabel(title(threadId) ?? ""); + return label.length > 0 ? `[${label}](${href})` : source; + }); } -export function parseThreadLinkHref( - href: string, -): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null { - if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; - const parts = href.slice(THREAD_LINK_HREF_PREFIX.length).split("/"); - if (parts.length !== 2) return null; - try { - const environmentId = decodeEnvironmentId(decodeURIComponent(parts[0]!)); - const threadId = decodeThreadId(decodeURIComponent(parts[1]!)); - return Option.isSome(environmentId) && Option.isSome(threadId) - ? { environmentId: environmentId.value, threadId: threadId.value } - : null; - } catch { - // Malformed percent encoding. - return null; - } +function sanitizeLinkLabel(label: string): string { + return label + .replace(/[[\]\\\r\n]/g, " ") + .replace(/\s+/g, " ") + .trim(); } From 546587972e9b32475ddf1688f6f7d91caa3e932d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:44:43 -0700 Subject: [PATCH 2/9] fix(web): copied thread chips keep a valid Markdown link A label with ] or \ broke the copied link. Build it with the same sanitizer the live relabel uses. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatMarkdown.tsx | 8 ++++++-- packages/shared/src/threadLinks.test.ts | 7 ++++++- packages/shared/src/threadLinks.ts | 20 +++++++++++--------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1340804ffaac..29f5ac1dd997 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -88,7 +88,11 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; -import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks"; +import { + formatThreadLink, + parseThreadLinkHref, + THREAD_LINK_PROTOCOL, +} from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import { ThreadContextChip } from "./ThreadContextChip"; import remarkGfm from "remark-gfm"; @@ -3174,7 +3178,7 @@ const CHAT_MARKDOWN_COMPONENTS = { return environmentId ? ( ) : ( {title} diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index eaa582c31635..d0bf36933d5e 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { parseThreadLinkHref, relabelThreadLinks } from "./threadLinks.ts"; +import { formatThreadLink, parseThreadLinkHref, relabelThreadLinks } from "./threadLinks.ts"; describe("thread links", () => { it("takes the thread id verbatim, percent escapes included", () => { @@ -16,6 +16,11 @@ describe("thread links", () => { expect(parseThreadLinkHref("t3-thread://v1/ ")).toBeNull(); }); + it("formats a label that would otherwise break the Markdown link", () => { + expect(formatThreadLink("t1", "Fix [ci] \\ build")).toBe("[Fix ci build](t3-thread://v1/t1)"); + expect(formatThreadLink("t1", " ] ")).toBe("[t1](t3-thread://v1/t1)"); + }); + it("relabels links with the current title and leaves unknown threads alone", () => { const titles = new Map([["renamed", "Fix [the] build\nnow"]]); expect( diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index ee027a00f74f..2cde5985bba6 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -19,6 +19,15 @@ export function parseThreadLinkHref(href: string): ThreadId | null { return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length))); } +/** A thread link whose label survives Markdown: no brackets, backslashes, or line breaks. */ +export function formatThreadLink(threadId: string, label: string): string { + const cleaned = label + .replace(/[[\]\\\r\n]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return `[${cleaned || threadId}](${THREAD_LINK_HREF_PREFIX}${threadId})`; +} + export function hasThreadLinks(markdown: string): boolean { return markdown.includes(`](${THREAD_LINK_HREF_PREFIX}`); } @@ -31,14 +40,7 @@ export function relabelThreadLinks( if (!hasThreadLinks(markdown)) return markdown; return markdown.replace(THREAD_LINK, (source, _label: string, href: string) => { const threadId = parseThreadLinkHref(href); - const label = threadId === null ? "" : sanitizeLinkLabel(title(threadId) ?? ""); - return label.length > 0 ? `[${label}](${href})` : source; + const label = threadId === null ? undefined : title(threadId)?.trim(); + return threadId !== null && label ? formatThreadLink(threadId, label) : source; }); } - -function sanitizeLinkLabel(label: string): string { - return label - .replace(/[[\]\\\r\n]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} From 7f04023e5aa8c5a1d7248e5d215b04cfd8ca26c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:58:15 -0700 Subject: [PATCH 3/9] fix(web,mobile): thread links keep the project icon and skip code Web shows a thread link with its project icon again, as #16782 did, but the label is now the thread's live title. Mobile's relabel no longer rewrites links written inside code spans or fences. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatMarkdown.tsx | 19 +++----- .../components/chat/MarkdownThreadLink.tsx | 46 +++++++++++++++++++ packages/shared/src/threadLinks.test.ts | 19 ++++++++ packages/shared/src/threadLinks.ts | 8 +++- 4 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/chat/MarkdownThreadLink.tsx diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 29f5ac1dd997..d926f5aaf412 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -88,13 +88,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; -import { - formatThreadLink, - parseThreadLinkHref, - THREAD_LINK_PROTOCOL, -} from "@t3tools/shared/threadLinks"; +import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; -import { ThreadContextChip } from "./ThreadContextChip"; +import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; import remarkGfm from "remark-gfm"; import type { Processor } from "unified"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; @@ -3171,17 +3167,14 @@ const CHAT_MARKDOWN_COMPONENTS = { } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; - // A thread link is the same chip as an attached thread: current title, opens in the app. + // A thread link names a thread in this message's environment and opens it in the app. const linkedThreadId = href ? parseThreadLinkHref(href) : null; if (linkedThreadId) { - const title = hastPlainTextDeep(node) || linkedThreadId; + const label = hastPlainTextDeep(node) || linkedThreadId; return environmentId ? ( - + ) : ( - {title} + {label} ); } const contextReference = href ? parseComposerContextHref(href) : null; diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx new file mode 100644 index 000000000000..5c4df12201b4 --- /dev/null +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -0,0 +1,46 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import { Link } from "@tanstack/react-router"; +import { MessageSquareTextIcon } from "lucide-react"; + +import { useProject, useThreadShell } from "../../state/entities"; +import { ProjectFavicon } from "../ProjectFavicon"; + +/** + * A `t3-thread://` link in chat. It shows the thread's current title, so a rename + * reaches every message that links to it, and leads with the thread's project icon + * the way a web link leads with its favicon. `label` is what the message wrote, + * shown only when this client cannot see the thread. Opens the thread in the app. + */ +export function MarkdownThreadLink(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly label: string; +}) { + const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); + const project = useProject( + thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId), + ); + const title = thread?.title.trim() || props.label; + return ( + + + {project === null ? ( + + ) : ( + + )} + + {title} + + ); +} diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index d0bf36933d5e..d45164afc279 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -16,6 +16,25 @@ describe("thread links", () => { expect(parseThreadLinkHref("t3-thread://v1/ ")).toBeNull(); }); + it("leaves links inside code spans and fences as written", () => { + const markdown = [ + "Live [old](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.", + "```md", + "[old](t3-thread://v1/t1)", + "```", + "After [old](t3-thread://v1/t1)", + ].join("\n"); + expect(relabelThreadLinks(markdown, () => "New")).toBe( + [ + "Live [New](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.", + "```md", + "[old](t3-thread://v1/t1)", + "```", + "After [New](t3-thread://v1/t1)", + ].join("\n"), + ); + }); + it("formats a label that would otherwise break the Markdown link", () => { expect(formatThreadLink("t1", "Fix [ci] \\ build")).toBe("[Fix ci build](t3-thread://v1/t1)"); expect(formatThreadLink("t1", " ] ")).toBe("[t1](t3-thread://v1/t1)"); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index 2cde5985bba6..8d0356b1d6e9 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -9,7 +9,9 @@ import * as Schema from "effect/Schema"; */ export const THREAD_LINK_PROTOCOL = "t3-thread"; const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; -const THREAD_LINK = /\[([^\]\n]*)\]\((t3-thread:\/\/v1\/[^\s)]+)\)/g; +// Code comes first in the alternation so a link written inside a code span or fence is skipped. +const THREAD_LINK_OUTSIDE_CODE = + /(?(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(`+)[^\n]*?\4)|\[[^\]\n]*\]\((?t3-thread:\/\/v1\/[^\s)]+)\)/g; const decodeThreadId = Schema.decodeUnknownOption(ThreadId); @@ -38,7 +40,9 @@ export function relabelThreadLinks( title: (threadId: ThreadId) => string | undefined, ): string { if (!hasThreadLinks(markdown)) return markdown; - return markdown.replace(THREAD_LINK, (source, _label: string, href: string) => { + return markdown.replace(THREAD_LINK_OUTSIDE_CODE, (source, ...args) => { + const href = (args.at(-1) as { href?: string }).href; + if (href === undefined) return source; const threadId = parseThreadLinkHref(href); const label = threadId === null ? undefined : title(threadId)?.trim(); return threadId !== null && label ? formatThreadLink(threadId, label) : source; From 98a70c33dfad51265d3eb19c0ba5fbc9ad63cf91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 18:51:50 -0700 Subject: [PATCH 4/9] fix(server): migrate-dev-db copies only visible threads migrate-dev-db took a full VACUUM INTO snapshot of ~/.t3 before pruning it. On a long-lived install that is a 68 GB temporary copy and several minutes, nearly all of it events from deleted, archived, and settled threads that the prune throws away. It now attaches the source read-only and copies only the rows of visible thread families, in one read transaction. Same result, about 10 seconds, no full-size temp file. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/scripts/migrate-dev-db.test.ts | 40 +++++++ apps/server/scripts/migrate-dev-db.ts | 121 ++++++++++++++++----- 2 files changed, 133 insertions(+), 28 deletions(-) diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 731a67d94cb6..bf156c9316c9 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -142,6 +142,46 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { }), ); + it.effect("never copies a settled thread's rows, and new events append after the source's", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-dest-" }); + const source = yield* createFixtureSource(sourceDir); + const [sourceSequence] = yield* withDatabase( + source, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ seq: number }>` + SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`; + }), + ); + + // Keep every family, so only the copy can leave the settled one out. + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 100 }, + { sharedHome: sourceDir }, + ); + + const copied = yield* withDatabase( + result.databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const [settledRows] = yield* sql<{ count: number }>` + SELECT + (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'settled-thread') + + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'settled-thread') + AS count`; + const [sequence] = yield* sql<{ seq: number }>` + SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`; + return { settledRows: settledRows?.count, sequence: sequence?.seq }; + }), + ); + assert.equal(copied.settledRows, 0); + assert.equal(copied.sequence, sourceSequence?.seq); + }), + ); + it.effect("fails loudly on a migration slot collision", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 3e9121fa2a9f..b01390402b55 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -6,9 +6,12 @@ * * `vp run migrate-dev-db` from a worktree: * 1. Nukes `/.t3/userdata/statev2.sqlite`. - * 2. Snapshots the real db (`~/.t3/userdata/statev2.sqlite`, read-only - * VACUUM INTO) and prunes it to the most recently updated projects and, - * per project, the most recent threads that have fully stopped, with + * 2. Copies a slice of the real db (`~/.t3/userdata/statev2.sqlite`, + * attached read-only): the schema, and every row except those of + * deleted, archived, and settled threads, which are nearly all of a + * long-lived database. It then prunes that to the most recently updated + * projects and, per project, the most recent threads that have fully + * stopped, with * their forks and subagents. Working, settled, and archived threads, and * threads with pending recovery, are skipped, and scheduled tasks and * queued effects are dropped, so the dev server never adopts live work. @@ -28,6 +31,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; +import { pathToFileURL } from "node:url"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; @@ -242,6 +246,82 @@ const RECOVERY_KINDS: ReadonlyArray = [ "delegated-completions", ]; +/** Tables the prune empties. The copy skips their rows. */ +const CLEARED_TABLES: ReadonlyArray = [ + // Pending work the dev server would otherwise pick up and run. + "scheduled_tasks", + "orchestration_v2_effect_outbox", + "orchestration_v2_thread_launch_workflows", + "orchestration_command_receipts", + "provider_session_runtime", + "auth_sessions", + "auth_pairing_links", +]; + +/** Shared between threads; the prune keeps the ones a kept thread is bound to. */ +const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions"; + +/** Whether the thread aliased `root` is shown: not deleted, archived, or settled. */ +const rootIsVisible = (sql: SqlClient.SqlClient) => sql` + root.deleted_at IS NULL + AND json_extract(root.payload_json, '$.deletedAt') IS NULL + AND json_extract(root.payload_json, '$.archivedAt') IS NULL + AND json_extract(root.payload_json, '$.settledAt') IS NULL + AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'`; + +/** + * Copies the source into the empty snapshot, without the rows of thread families whose root is + * hidden. The prune drops those anyway, and they hold nearly all of a long-lived database's + * events, so copying them is what used to make this take a full-size copy of the real db. + * One transaction reads the source, so the copy is consistent while the real server writes. + */ +const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: string) { + const sql = yield* SqlClient.SqlClient; + yield* sql`ATTACH DATABASE ${`${pathToFileURL(sourcePath).href}?mode=ro`} AS src`; + const schema = yield* sql<{ type: string; name: string; sql: string }>` + SELECT type, name, sql FROM src.sqlite_master + WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'`; + const threadColumns = yield* sql<{ name: string; notnull: number }>` + SELECT m.name, c."notnull" FROM src.sqlite_master m, pragma_table_info(m.name, 'src') c + WHERE m.type = 'table' AND c.name = 'thread_id'`; + const threadIdNullable = new Map(threadColumns.map((row) => [row.name, row.notnull === 0])); + const tables = schema.filter((entry) => entry.type === "table"); + + yield* sql.withTransaction( + Effect.gen(function* () { + for (const table of tables) yield* sql.unsafe(table.sql).unprepared; + yield* sql`CREATE TEMP TABLE copied_threads (thread_id TEXT PRIMARY KEY)`; + yield* sql`INSERT OR IGNORE INTO copied_threads + SELECT family.thread_id FROM src.orchestration_v2_projection_threads family + JOIN src.orchestration_v2_projection_threads root ON root.thread_id = + COALESCE(json_extract(family.payload_json, '$.lineage.rootThreadId'), family.thread_id) + WHERE ${rootIsVisible(sql)}`; + for (const { name } of tables) { + if (CLEARED_TABLES.includes(name)) continue; + const nullable = threadIdNullable.get(name); + const filter = + name === "orchestration_events" + ? // Two ranges rather than `<>`, so SQLite seeks the (aggregate_kind, stream_id) + // index instead of scanning every event. + `WHERE (aggregate_kind = 'thread' AND stream_id IN (SELECT thread_id FROM copied_threads)) + OR aggregate_kind < 'thread' OR aggregate_kind > 'thread'` + : nullable === undefined || name === PROVIDER_SESSIONS_TABLE + ? "" + : `WHERE thread_id IN (SELECT thread_id FROM copied_threads)${nullable ? " OR thread_id IS NULL" : ""}`; + yield* sql.unsafe(`INSERT INTO main."${name}" SELECT * FROM src."${name}" ${filter}`) + .unprepared; + } + // The source's AUTOINCREMENT high-water marks, so new events append after them. + yield* sql`DELETE FROM main.sqlite_sequence`; + yield* sql`INSERT INTO main.sqlite_sequence SELECT name, seq FROM src.sqlite_sequence`; + for (const entry of schema) { + if (entry.type !== "table") yield* sql.unsafe(entry.sql).unprepared; + } + }), + ); + yield* sql`DETACH DATABASE src`; +}); + const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { const sql = yield* SqlClient.SqlClient; @@ -278,11 +358,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at FROM thread_families f JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id - WHERE root.deleted_at IS NULL - AND json_extract(root.payload_json, '$.deletedAt') IS NULL - AND json_extract(root.payload_json, '$.archivedAt') IS NULL - AND json_extract(root.payload_json, '$.settledAt') IS NULL - AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled' + WHERE ${rootIsVisible(sql)} GROUP BY f.root_id, root.project_id HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`; @@ -323,13 +399,13 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra const threadTables = yield* sql<{ name: string }>` SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c WHERE m.type = 'table' AND c.name = 'thread_id' - AND m.name <> 'orchestration_v2_projection_provider_sessions'`; + AND m.name <> ${PROVIDER_SESSIONS_TABLE}`; yield* sql.withTransaction( Effect.gen(function* () { yield* sql`DELETE FROM projection_projects WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`; - yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions + yield* sql`DELETE FROM ${sql(PROVIDER_SESSIONS_TABLE)} WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads) AND provider_session_id NOT IN ( SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings @@ -348,14 +424,9 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra AND stream_id NOT IN (SELECT thread_id FROM kept_threads)) OR (aggregate_kind = 'project' AND stream_id NOT IN (SELECT project_id FROM kept_projects))`; - // Pending work the dev server would otherwise pick up and run. - yield* sql`DELETE FROM scheduled_tasks`; - yield* sql`DELETE FROM orchestration_v2_effect_outbox`; - yield* sql`DELETE FROM orchestration_v2_thread_launch_workflows`; - yield* sql`DELETE FROM orchestration_command_receipts`; - yield* sql`DELETE FROM provider_session_runtime`; - yield* sql`DELETE FROM auth_sessions`; - yield* sql`DELETE FROM auth_pairing_links`; + for (const table of CLEARED_TABLES) { + yield* sql`DELETE FROM ${sql(table)}`; + } }), ); @@ -457,22 +528,16 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( ); yield* removeDatabaseFiles(snapshotPath); - // The snapshot is a full-size copy of the source; make sure it is removed - // even when a phase fails partway through. + // Make sure the snapshot is removed even when a phase fails partway through. const { executedMigrations, pruned } = yield* Effect.gen(function* () { - yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`); - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`VACUUM INTO ${snapshotPath}`; - }).pipe( - Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })), + yield* Console.log(`Copying visible threads from ${sourcePath} (read-only)...`); + yield* copySourceSlice(sourcePath).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), wrapPhase("snapshot", sourcePath), ); // Migrate before pruning: a source older than this checkout would // otherwise crash the prune queries on columns that don't exist yet. - // Running against the full snapshot also exercises new migrations on the - // same data volume the real database would face. yield* Console.log("Running migrations on the snapshot..."); const executed = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; From adc20b30345ac961bcdce2a85b3cb3a5c7d4ce8d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 19:16:54 -0700 Subject: [PATCH 5/9] fix(server,web,mobile): agents get the thread link format where they need it In a real run the agent invented `t3://thread/`, which rendered as a dead link. The thread tools now state the exact format, and say not to URL-encode the id. Clients also accept a percent-encoded id when the id as written names no thread, since agents encode anyway. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/mcp/toolkits/orchestrator/tools.ts | 4 +-- apps/server/src/mcp/toolkits/project/tools.ts | 2 +- .../provider/T3OrchestrationInstructions.ts | 2 +- .../components/chat/MarkdownThreadLink.tsx | 14 ++++++--- packages/shared/src/threadLinks.test.ts | 15 +++++++++ packages/shared/src/threadLinks.ts | 31 ++++++++++++++++--- 6 files changed, 55 insertions(+), 13 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 92e052f3e988..e7ef07101a39 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "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.", + "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. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "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 also reports its snooze state.", + "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 also reports its snooze state. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index ab04bd5a195b..ee489d4e104c 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - '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:"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/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.', + '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:"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/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. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. 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.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index 8f0eb5014064..2280d4d25619 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -11,7 +11,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. -- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`. T3 Code opens the thread in the app and shows its current title. +- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title. ### Choose the workspace before starting a new thread diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index 5c4df12201b4..d5bb64c25ffb 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -1,6 +1,6 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import { formatThreadLink, percentDecodedThreadLinkId } from "@t3tools/shared/threadLinks"; import { Link } from "@tanstack/react-router"; import { MessageSquareTextIcon } from "lucide-react"; @@ -18,7 +18,13 @@ export function MarkdownThreadLink(props: { readonly threadId: ThreadId; readonly label: string; }) { - const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); + const decodedId = percentDecodedThreadLinkId(props.threadId); + const written = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); + const decoded = useThreadShell( + written === null && decodedId !== null ? scopeThreadRef(props.environmentId, decodedId) : null, + ); + const thread = written ?? decoded; + const threadId = thread?.id ?? props.threadId; const project = useProject( thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId), ); @@ -26,9 +32,9 @@ export function MarkdownThreadLink(props: { return ( { expect(parseThreadLinkHref("t3-thread://v1/ ")).toBeNull(); }); + it("resolves a percent-encoded id when the id as written names no thread", () => { + const titles = new Map([ + ["thread:project:1", "Decoded"], + ["provider%3A1", "Literal escape"], + ]); + expect( + relabelThreadLinks( + "[a](t3-thread://v1/thread%3Aproject%3A1) [b](t3-thread://v1/provider%3A1)", + (threadId) => titles.get(threadId), + ), + ).toBe( + "[Decoded](t3-thread://v1/thread:project:1) [Literal escape](t3-thread://v1/provider%3A1)", + ); + }); + it("leaves links inside code spans and fences as written", () => { const markdown = [ "Live [old](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.", diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index 8d0356b1d6e9..fc39937acaee 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -15,12 +15,25 @@ const THREAD_LINK_OUTSIDE_CODE = const decodeThreadId = Schema.decodeUnknownOption(ThreadId); -/** The id is taken verbatim: thread ids can hold percent escapes of their own. */ +/** The id as written. Thread ids can hold percent escapes of their own, so it is not decoded. */ export function parseThreadLinkHref(href: string): ThreadId | null { if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length))); } +/** + * Agents often percent-encode the id anyway. When the id as written names no thread, clients try + * this decoded form. Null when decoding changes nothing or fails. + */ +export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null { + try { + const decoded = decodeURIComponent(threadId); + return decoded === threadId ? null : Option.getOrNull(decodeThreadId(decoded)); + } catch { + return null; + } +} + /** A thread link whose label survives Markdown: no brackets, backslashes, or line breaks. */ export function formatThreadLink(threadId: string, label: string): string { const cleaned = label @@ -34,7 +47,10 @@ export function hasThreadLinks(markdown: string): boolean { return markdown.includes(`](${THREAD_LINK_HREF_PREFIX}`); } -/** Relabels each thread link with `title(threadId)`; a link it returns nothing for keeps its label. */ +/** + * Relabels each thread link with `title(threadId)`, pointing it at the thread that title came from. + * A link it returns nothing for keeps its label. + */ export function relabelThreadLinks( markdown: string, title: (threadId: ThreadId) => string | undefined, @@ -43,8 +59,13 @@ export function relabelThreadLinks( return markdown.replace(THREAD_LINK_OUTSIDE_CODE, (source, ...args) => { const href = (args.at(-1) as { href?: string }).href; if (href === undefined) return source; - const threadId = parseThreadLinkHref(href); - const label = threadId === null ? undefined : title(threadId)?.trim(); - return threadId !== null && label ? formatThreadLink(threadId, label) : source; + const written = parseThreadLinkHref(href); + if (written === null) return source; + const decoded = percentDecodedThreadLinkId(written); + for (const threadId of decoded === null ? [written] : [written, decoded]) { + const label = title(threadId)?.trim(); + if (label) return formatThreadLink(threadId, label); + } + return source; }); } From 23c65de00077fef434d30c9acaa9862a91c5ada4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 20:16:25 -0700 Subject: [PATCH 6/9] fix(server): import node:url as a namespace in migrate-dev-db Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/scripts/migrate-dev-db.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index b01390402b55..ff41df9b72f6 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -31,7 +31,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; -import { pathToFileURL } from "node:url"; +import * as NodeURL from "node:url"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; @@ -277,7 +277,7 @@ const rootIsVisible = (sql: SqlClient.SqlClient) => sql` */ const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: string) { const sql = yield* SqlClient.SqlClient; - yield* sql`ATTACH DATABASE ${`${pathToFileURL(sourcePath).href}?mode=ro`} AS src`; + yield* sql`ATTACH DATABASE ${`${NodeURL.pathToFileURL(sourcePath).href}?mode=ro`} AS src`; const schema = yield* sql<{ type: string; name: string; sql: string }>` SELECT type, name, sql FROM src.sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'`; From 10e984d22796edefe73131c3acf2dd21da283daf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 20:20:11 -0700 Subject: [PATCH 7/9] fix(shared): a thread link keeps its id when that thread has no title The decoded-id fallback is only for an id that names no thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/shared/src/threadLinks.test.ts | 8 ++++++++ packages/shared/src/threadLinks.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index 2f65e6876bc4..087aa8894f6a 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -29,6 +29,14 @@ describe("thread links", () => { ).toBe( "[Decoded](t3-thread://v1/thread:project:1) [Literal escape](t3-thread://v1/provider%3A1)", ); + // A thread with an empty title still exists, so its link is not redirected. + const untitled = new Map([ + ["a%3A1", ""], + ["a:1", "Other"], + ]); + expect( + relabelThreadLinks("[Kept](t3-thread://v1/a%3A1)", (threadId) => untitled.get(threadId)), + ).toBe("[Kept](t3-thread://v1/a%3A1)"); }); it("leaves links inside code spans and fences as written", () => { diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index fc39937acaee..15384aeea4d4 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -61,11 +61,13 @@ export function relabelThreadLinks( if (href === undefined) return source; const written = parseThreadLinkHref(href); if (written === null) return source; + // The decoded id only stands in when the id as written names no thread. const decoded = percentDecodedThreadLinkId(written); - for (const threadId of decoded === null ? [written] : [written, decoded]) { - const label = title(threadId)?.trim(); - if (label) return formatThreadLink(threadId, label); - } - return source; + const threadId = + title(written) === undefined && decoded !== null && title(decoded) !== undefined + ? decoded + : written; + const label = title(threadId)?.trim(); + return label ? formatThreadLink(threadId, label) : source; }); } From 3bf3bdef3d1d0414422747e66f3edee109a8c460 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 20:24:46 -0700 Subject: [PATCH 8/9] fix(server): migrate-dev-db upgrades sources from before the V2 tables A source without orchestration_v2_projection_threads has nothing to filter on, so it is copied whole, as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/scripts/migrate-dev-db.test.ts | 19 ++++++++++++++++++ apps/server/scripts/migrate-dev-db.ts | 23 +++++++++++++--------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index bf156c9316c9..07d3dc61c791 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -182,6 +182,25 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { }), ); + it.effect("upgrades a source from before the V2 thread tables", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-dest-" }); + const stateDir = path.join(sourceDir, "userdata"); + const source = path.join(stateDir, "statev2.sqlite"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* withDatabase(source, runMigrations({ toMigrationInclusive: 54 })); + + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ); + assert.include(result.executedMigrations, "55_OrchestrationV2"); + }), + ); + it.effect("fails loudly on a migration slot collision", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index ff41df9b72f6..4cfdf6233346 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -273,7 +273,8 @@ const rootIsVisible = (sql: SqlClient.SqlClient) => sql` * Copies the source into the empty snapshot, without the rows of thread families whose root is * hidden. The prune drops those anyway, and they hold nearly all of a long-lived database's * events, so copying them is what used to make this take a full-size copy of the real db. - * One transaction reads the source, so the copy is consistent while the real server writes. + * A source from before the V2 thread tables has no visibility to filter on, so it is copied + * whole. One transaction reads the source, so the copy is consistent while the real server writes. */ const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: string) { const sql = yield* SqlClient.SqlClient; @@ -286,21 +287,25 @@ const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: WHERE m.type = 'table' AND c.name = 'thread_id'`; const threadIdNullable = new Map(threadColumns.map((row) => [row.name, row.notnull === 0])); const tables = schema.filter((entry) => entry.type === "table"); + const filtered = tables.some((table) => table.name === "orchestration_v2_projection_threads"); yield* sql.withTransaction( Effect.gen(function* () { for (const table of tables) yield* sql.unsafe(table.sql).unprepared; - yield* sql`CREATE TEMP TABLE copied_threads (thread_id TEXT PRIMARY KEY)`; - yield* sql`INSERT OR IGNORE INTO copied_threads - SELECT family.thread_id FROM src.orchestration_v2_projection_threads family - JOIN src.orchestration_v2_projection_threads root ON root.thread_id = - COALESCE(json_extract(family.payload_json, '$.lineage.rootThreadId'), family.thread_id) - WHERE ${rootIsVisible(sql)}`; + if (filtered) { + yield* sql`CREATE TEMP TABLE copied_threads (thread_id TEXT PRIMARY KEY)`; + yield* sql`INSERT OR IGNORE INTO copied_threads + SELECT family.thread_id FROM src.orchestration_v2_projection_threads family + JOIN src.orchestration_v2_projection_threads root ON root.thread_id = + COALESCE(json_extract(family.payload_json, '$.lineage.rootThreadId'), family.thread_id) + WHERE ${rootIsVisible(sql)}`; + } for (const { name } of tables) { if (CLEARED_TABLES.includes(name)) continue; const nullable = threadIdNullable.get(name); - const filter = - name === "orchestration_events" + const filter = !filtered + ? "" + : name === "orchestration_events" ? // Two ranges rather than `<>`, so SQLite seeks the (aggregate_kind, stream_id) // index instead of scanning every event. `WHERE (aggregate_kind = 'thread' AND stream_id IN (SELECT thread_id FROM copied_threads)) From 156a1a3a5f1f717683d957ef4ba2c7d849fc6fd5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 20:28:40 -0700 Subject: [PATCH 9/9] fix(web): an unresolved thread link says the thread is unavailable Same hint as an attached thread chip. The link stays clickable, since archived threads are not in the shell index but still open. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/chat/MarkdownThreadLink.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index d5bb64c25ffb..4b1b44d7c62b 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -33,7 +33,8 @@ export function MarkdownThreadLink(props: {