authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 20:59:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 20:59:26-07:00
log0f09627a111f7e9adc22c256c725565672676f1a
treef7bd5ab7bea6ef95fb848b3aefcc2600386f59d5
parent04ad17425a80484bf323a7d5076bd477a9499119
signature Signed by PGP key B5690EEEBB952194

fix(server,web,mobile): thread links reference the thread id, not a baked title (#17017)


18 files changed, 384 insertions(+), 180 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+20-10
...@@ -198,6 +198,7 @@ import {...@@ -198,6 +198,7 @@ import {
198} from "../../state/assets";198} from "../../state/assets";
199import { useAtomQueryRunner } from "../../state/use-atom-query-runner";199import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
200import { usePreparedConnection } from "../../state/session";200import { usePreparedConnection } from "../../state/session";
201import { useLiveThreadLinkLabels } from "../../state/entities";
201import { useThreadSelection } from "../../state/use-thread-selection";202import { useThreadSelection } from "../../state/use-thread-selection";
202import { composerDocumentAttachmentRecord } from "../../lib/composerContext";203import { composerDocumentAttachmentRecord } from "../../lib/composerContext";
203import * as Option from "effect/Option";204import * as Option from "effect/Option";
...@@ -906,16 +907,15 @@ interface MarkdownLinkHandlers {...@@ -906,16 +907,15 @@ interface MarkdownLinkHandlers {
906907
907const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {908const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
908 readonly markdown: string;909 readonly markdown: string;
910 readonly environmentId: EnvironmentId;
909 readonly markdownStyles: MarkdownStyleSet;911 readonly markdownStyles: MarkdownStyleSet;
910 readonly linkHandlers: MarkdownLinkHandlers;912 readonly linkHandlers: MarkdownLinkHandlers;
911 readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;913 readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
912 readonly renderImage: MarkdownImageRenderer;914 readonly renderImage: MarkdownImageRenderer;
913 readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;915 readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
914}) {916}) {
915 const segments = useMemo(917 const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
916 () => splitCodexArtifactTemplateMarkdown(props.markdown),918 const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]);
917 [props.markdown],
918 );
919919
920 return segments.map((segment) => {920 return segments.map((segment) => {
921 if (segment.kind === "artifact-template") {921 if (segment.kind === "artifact-template") {
...@@ -1905,6 +1905,7 @@ function renderFeedEntry(...@@ -1905,6 +1905,7 @@ function renderFeedEntry(
1905 <MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>1905 <MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>
1906 <AssistantMarkdownContent1906 <AssistantMarkdownContent
1907 markdown={renderedText}1907 markdown={renderedText}
1908 environmentId={props.environmentId}
1908 markdownStyles={styles}1909 markdownStyles={styles}
1909 linkHandlers={props.markdownLinkHandlers}1910 linkHandlers={props.markdownLinkHandlers}
1910 onUseArtifactTemplate={props.onUseArtifactTemplate}1911 onUseArtifactTemplate={props.onUseArtifactTemplate}
...@@ -2002,7 +2003,8 @@ function UserMessageContent(props: UserMessageContentProps) {...@@ -2002,7 +2003,8 @@ function UserMessageContent(props: UserMessageContentProps) {
2002 const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null);2003 const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null);
2003 const navigation = useNavigation();2004 const navigation = useNavigation();
2004 const { selectedThread } = useThreadSelection();2005 const { selectedThread } = useThreadSelection();
2005 const text = replaceComposerContextReferences(props.text, (ref) => {2006 const liveText = useLiveThreadLinkLabels(props.text, props.environmentId);
2007 const text = replaceComposerContextReferences(liveText, (ref) => {
2006 const available = props.context?.records.some((record) => record.contextId === ref.contextId);2008 const available = props.context?.records.some((record) => record.contextId === ref.contextId);
2007 return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`;2009 return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`;
2008 });2010 });
...@@ -2292,11 +2294,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2292,11 +2294,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2292 const userBubbleColor = theme["--color-user-bubble"];2294 const userBubbleColor = theme["--color-user-bubble"];
2293 const onMarkdownLinkPress = useCallback(2295 const onMarkdownLinkPress = useCallback(
2294 (href: string) => {2296 (href: string) => {
2295 const threadLink = parseThreadLinkHref(href);2297 // A thread link names a thread in this feed's environment.
2296 if (threadLink) {2298 const linkedThreadId = parseThreadLinkHref(href);
2299 if (linkedThreadId) {
2297 navigation.navigate("Thread", {2300 navigation.navigate("Thread", {
2298 environmentId: String(threadLink.environmentId),2301 environmentId: String(props.environmentId),
2299 threadId: String(threadLink.threadId),2302 threadId: String(linkedThreadId),
2300 });2303 });
2301 return;2304 return;
2302 }2305 }
...@@ -2504,13 +2507,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2504,13 +2507,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2504 (text: string) => (2507 (text: string) => (
2505 <AssistantMarkdownContent2508 <AssistantMarkdownContent
2506 markdown={text}2509 markdown={text}
2510 environmentId={props.environmentId}
2507 markdownStyles={markdownStyles.assistant}2511 markdownStyles={markdownStyles.assistant}
2508 linkHandlers={markdownLinkHandlers}2512 linkHandlers={markdownLinkHandlers}
2509 renderImage={renderMarkdownImage}2513 renderImage={renderMarkdownImage}
2510 skills={props.skills}2514 skills={props.skills}
2511 />2515 />
2512 ),2516 ),
2513 [markdownStyles.assistant, markdownLinkHandlers, renderMarkdownImage, props.skills],2517 [
2518 markdownStyles.assistant,
2519 markdownLinkHandlers,
2520 renderMarkdownImage,
2521 props.skills,
2522 props.environmentId,
2523 ],
2514 );2524 );
2515 const reviewCommentColors = useReviewCommentColors();2525 const reviewCommentColors = useReviewCommentColors();
2516 const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null;2526 const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null;
apps/mobile/src/lib/nativeMarkdownText.test.ts+2-2
...@@ -91,12 +91,12 @@ describe("nativeMarkdownTextRuns", () => {...@@ -91,12 +91,12 @@ describe("nativeMarkdownTextRuns", () => {
91 children: [91 children: [
92 {92 {
93 type: "link",93 type: "link",
94 href: "t3-thread://v1/env/thread-1",94 href: "t3-thread://v1/thread-1",
95 children: [{ type: "text", content: "Fix the build" }],95 children: [{ type: "text", content: "Fix the build" }],
96 },96 },
97 ],97 ],
98 }),98 }),
99 ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]);99 ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/thread-1" }]);
100 });100 });
101101
102 it("preserves the destination of a link with a code-formatted label", () => {102 it("preserves the destination of a link with a code-formatted label", () => {
apps/mobile/src/state/entities.ts+36
...@@ -12,8 +12,11 @@ import type {...@@ -12,8 +12,11 @@ import type {
12 ScopedProjectRef,12 ScopedProjectRef,
13 ScopedThreadRef,13 ScopedThreadRef,
14 ServerConfig,14 ServerConfig,
15 ThreadId,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks";
16import { Atom } from "effect/reactivity";18import { Atom } from "effect/reactivity";
19import { useMemo } from "react";
1720
18import { environmentProjects } from "./projects";21import { environmentProjects } from "./projects";
19import { environmentServerConfigsAtom, serverEnvironment } from "./server";22import { environmentServerConfigsAtom, serverEnvironment } from "./server";
...@@ -28,6 +31,25 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p...@@ -28,6 +31,25 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p
28const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(31const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
29 Atom.withLabel("mobile-server-config:empty"),32 Atom.withLabel("mobile-server-config:empty"),
30);33);
34const EMPTY_THREAD_TITLES: ReadonlyMap<ThreadId, string> = new Map();
35const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe(
36 Atom.withLabel("mobile-thread-titles:empty"),
37);
38
39/** Thread titles in one environment. Emits when a title changes, not on every shell update. */
40const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => {
41 let previous = EMPTY_THREAD_TITLES;
42 return Atom.make((get) => {
43 const index = get(environmentThreadShells.environmentThreadIndexAtom(environmentId));
44 const unchanged =
45 index.size === previous.size &&
46 Array.from(index).every(([threadId, shell]) => previous.get(threadId) === shell.title);
47 if (!unchanged) {
48 previous = new Map(Array.from(index, ([threadId, shell]) => [threadId, shell.title]));
49 }
50 return previous;
51 }).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`));
52});
3153
32/** Resolves when the project event reaches the live client store. */54/** Resolves when the project event reaches the live client store. */
33export function waitForProject(55export function waitForProject(
...@@ -76,6 +98,20 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh...@@ -76,6 +98,20 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh
76 );98 );
77}99}
78100
101/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */
102export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
103 const titles = useAtomValue(
104 hasThreadLinks(markdown) ? threadTitlesAtom(environmentId) : EMPTY_THREAD_TITLES_ATOM,
105 );
106 return useMemo(
107 () =>
108 titles.size === 0
109 ? markdown
110 : relabelThreadLinks(markdown, (threadId) => titles.get(threadId)),
111 [markdown, titles],
112 );
113}
114
79export function useEnvironmentServerConfig(115export function useEnvironmentServerConfig(
80 environmentId: EnvironmentId | null,116 environmentId: EnvironmentId | null,
81): ServerConfig | null {117): ServerConfig | null {
apps/server/scripts/migrate-dev-db.test.ts+59
...@@ -142,6 +142,65 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {...@@ -142,6 +142,65 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
142 }),142 }),
143 );143 );
144144
145 it.effect("never copies a settled thread's rows, and new events append after the source's", () =>
146 Effect.gen(function* () {
147 const fs = yield* FileSystem.FileSystem;
148 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" });
149 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-dest-" });
150 const source = yield* createFixtureSource(sourceDir);
151 const [sourceSequence] = yield* withDatabase(
152 source,
153 Effect.gen(function* () {
154 const sql = yield* SqlClient.SqlClient;
155 return yield* sql<{ seq: number }>`
156 SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`;
157 }),
158 );
159
160 // Keep every family, so only the copy can leave the settled one out.
161 const result = yield* runMigrateDevDb(
162 { baseDir: destDir, source, projects: 5, threadsPerProject: 100 },
163 { sharedHome: sourceDir },
164 );
165
166 const copied = yield* withDatabase(
167 result.databasePath,
168 Effect.gen(function* () {
169 const sql = yield* SqlClient.SqlClient;
170 const [settledRows] = yield* sql<{ count: number }>`
171 SELECT
172 (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'settled-thread')
173 + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'settled-thread')
174 AS count`;
175 const [sequence] = yield* sql<{ seq: number }>`
176 SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`;
177 return { settledRows: settledRows?.count, sequence: sequence?.seq };
178 }),
179 );
180 assert.equal(copied.settledRows, 0);
181 assert.equal(copied.sequence, sourceSequence?.seq);
182 }),
183 );
184
185 it.effect("upgrades a source from before the V2 thread tables", () =>
186 Effect.gen(function* () {
187 const fs = yield* FileSystem.FileSystem;
188 const path = yield* Path.Path;
189 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-" });
190 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-dest-" });
191 const stateDir = path.join(sourceDir, "userdata");
192 const source = path.join(stateDir, "statev2.sqlite");
193 yield* fs.makeDirectory(stateDir, { recursive: true });
194 yield* withDatabase(source, runMigrations({ toMigrationInclusive: 54 }));
195
196 const result = yield* runMigrateDevDb(
197 { baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
198 { sharedHome: sourceDir },
199 );
200 assert.include(result.executedMigrations, "55_OrchestrationV2");
201 }),
202 );
203
145 it.effect("fails loudly on a migration slot collision", () =>204 it.effect("fails loudly on a migration slot collision", () =>
146 Effect.gen(function* () {205 Effect.gen(function* () {
147 const fs = yield* FileSystem.FileSystem;206 const fs = yield* FileSystem.FileSystem;
apps/server/scripts/migrate-dev-db.ts+98-28
...@@ -6,9 +6,12 @@...@@ -6,9 +6,12 @@
6 *6 *
7 * `vp run migrate-dev-db` from a worktree:7 * `vp run migrate-dev-db` from a worktree:
8 * 1. Nukes `<worktree>/.t3/userdata/statev2.sqlite`.8 * 1. Nukes `<worktree>/.t3/userdata/statev2.sqlite`.
9 * 2. Snapshots the real db (`~/.t3/userdata/statev2.sqlite`, read-only9 * 2. Copies a slice of the real db (`~/.t3/userdata/statev2.sqlite`,
10 * VACUUM INTO) and prunes it to the most recently updated projects and,10 * attached read-only): the schema, and every row except those of
11 * per project, the most recent threads that have fully stopped, with11 * deleted, archived, and settled threads, which are nearly all of a
12 * long-lived database. It then prunes that to the most recently updated
13 * projects and, per project, the most recent threads that have fully
14 * stopped, with
12 * their forks and subagents. Working, settled, and archived threads, and15 * their forks and subagents. Working, settled, and archived threads, and
13 * threads with pending recovery, are skipped, and scheduled tasks and16 * threads with pending recovery, are skipped, and scheduled tasks and
14 * queued effects are dropped, so the dev server never adopts live work.17 * queued effects are dropped, so the dev server never adopts live work.
...@@ -28,6 +31,7 @@...@@ -28,6 +31,7 @@
28import * as NodeRuntime from "@effect/platform-node/NodeRuntime";31import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
29import * as NodeServices from "@effect/platform-node/NodeServices";32import * as NodeServices from "@effect/platform-node/NodeServices";
30import * as NodeOS from "node:os";33import * as NodeOS from "node:os";
34import * as NodeURL from "node:url";
31import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";35import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
32import * as Console from "effect/Console";36import * as Console from "effect/Console";
33import * as Effect from "effect/Effect";37import * as Effect from "effect/Effect";
...@@ -242,6 +246,87 @@ const RECOVERY_KINDS: ReadonlyArray<ProjectionStore.ProjectionRecoveryKind> = [...@@ -242,6 +246,87 @@ const RECOVERY_KINDS: ReadonlyArray<ProjectionStore.ProjectionRecoveryKind> = [
242 "delegated-completions",246 "delegated-completions",
243];247];
244248
249/** Tables the prune empties. The copy skips their rows. */
250const CLEARED_TABLES: ReadonlyArray<string> = [
251 // Pending work the dev server would otherwise pick up and run.
252 "scheduled_tasks",
253 "orchestration_v2_effect_outbox",
254 "orchestration_v2_thread_launch_workflows",
255 "orchestration_command_receipts",
256 "provider_session_runtime",
257 "auth_sessions",
258 "auth_pairing_links",
259];
260
261/** Shared between threads; the prune keeps the ones a kept thread is bound to. */
262const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions";
263
264/** Whether the thread aliased `root` is shown: not deleted, archived, or settled. */
265const rootIsVisible = (sql: SqlClient.SqlClient) => sql`
266 root.deleted_at IS NULL
267 AND json_extract(root.payload_json, '$.deletedAt') IS NULL
268 AND json_extract(root.payload_json, '$.archivedAt') IS NULL
269 AND json_extract(root.payload_json, '$.settledAt') IS NULL
270 AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'`;
271
272/**
273 * Copies the source into the empty snapshot, without the rows of thread families whose root is
274 * hidden. The prune drops those anyway, and they hold nearly all of a long-lived database's
275 * events, so copying them is what used to make this take a full-size copy of the real db.
276 * A source from before the V2 thread tables has no visibility to filter on, so it is copied
277 * whole. One transaction reads the source, so the copy is consistent while the real server writes.
278 */
279const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: string) {
280 const sql = yield* SqlClient.SqlClient;
281 yield* sql`ATTACH DATABASE ${`${NodeURL.pathToFileURL(sourcePath).href}?mode=ro`} AS src`;
282 const schema = yield* sql<{ type: string; name: string; sql: string }>`
283 SELECT type, name, sql FROM src.sqlite_master
284 WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'`;
285 const threadColumns = yield* sql<{ name: string; notnull: number }>`
286 SELECT m.name, c."notnull" FROM src.sqlite_master m, pragma_table_info(m.name, 'src') c
287 WHERE m.type = 'table' AND c.name = 'thread_id'`;
288 const threadIdNullable = new Map(threadColumns.map((row) => [row.name, row.notnull === 0]));
289 const tables = schema.filter((entry) => entry.type === "table");
290 const filtered = tables.some((table) => table.name === "orchestration_v2_projection_threads");
291
292 yield* sql.withTransaction(
293 Effect.gen(function* () {
294 for (const table of tables) yield* sql.unsafe(table.sql).unprepared;
295 if (filtered) {
296 yield* sql`CREATE TEMP TABLE copied_threads (thread_id TEXT PRIMARY KEY)`;
297 yield* sql`INSERT OR IGNORE INTO copied_threads
298 SELECT family.thread_id FROM src.orchestration_v2_projection_threads family
299 JOIN src.orchestration_v2_projection_threads root ON root.thread_id =
300 COALESCE(json_extract(family.payload_json, '$.lineage.rootThreadId'), family.thread_id)
301 WHERE ${rootIsVisible(sql)}`;
302 }
303 for (const { name } of tables) {
304 if (CLEARED_TABLES.includes(name)) continue;
305 const nullable = threadIdNullable.get(name);
306 const filter = !filtered
307 ? ""
308 : name === "orchestration_events"
309 ? // Two ranges rather than `<>`, so SQLite seeks the (aggregate_kind, stream_id)
310 // index instead of scanning every event.
311 `WHERE (aggregate_kind = 'thread' AND stream_id IN (SELECT thread_id FROM copied_threads))
312 OR aggregate_kind < 'thread' OR aggregate_kind > 'thread'`
313 : nullable === undefined || name === PROVIDER_SESSIONS_TABLE
314 ? ""
315 : `WHERE thread_id IN (SELECT thread_id FROM copied_threads)${nullable ? " OR thread_id IS NULL" : ""}`;
316 yield* sql.unsafe(`INSERT INTO main."${name}" SELECT * FROM src."${name}" ${filter}`)
317 .unprepared;
318 }
319 // The source's AUTOINCREMENT high-water marks, so new events append after them.
320 yield* sql`DELETE FROM main.sqlite_sequence`;
321 yield* sql`INSERT INTO main.sqlite_sequence SELECT name, seq FROM src.sqlite_sequence`;
322 for (const entry of schema) {
323 if (entry.type !== "table") yield* sql.unsafe(entry.sql).unprepared;
324 }
325 }),
326 );
327 yield* sql`DETACH DATABASE src`;
328});
329
245const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) {330const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) {
246 const sql = yield* SqlClient.SqlClient;331 const sql = yield* SqlClient.SqlClient;
247332
...@@ -278,11 +363,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra...@@ -278,11 +363,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
278 SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at363 SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at
279 FROM thread_families f364 FROM thread_families f
280 JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id365 JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id
281 WHERE root.deleted_at IS NULL366 WHERE ${rootIsVisible(sql)}
282 AND json_extract(root.payload_json, '$.deletedAt') IS NULL
283 AND json_extract(root.payload_json, '$.archivedAt') IS NULL
284 AND json_extract(root.payload_json, '$.settledAt') IS NULL
285 AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'
286 GROUP BY f.root_id, root.project_id367 GROUP BY f.root_id, root.project_id
287 HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`;368 HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`;
288369
...@@ -323,13 +404,13 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra...@@ -323,13 +404,13 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
323 const threadTables = yield* sql<{ name: string }>`404 const threadTables = yield* sql<{ name: string }>`
324 SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c405 SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c
325 WHERE m.type = 'table' AND c.name = 'thread_id'406 WHERE m.type = 'table' AND c.name = 'thread_id'
326 AND m.name <> 'orchestration_v2_projection_provider_sessions'`;407 AND m.name <> ${PROVIDER_SESSIONS_TABLE}`;
327408
328 yield* sql.withTransaction(409 yield* sql.withTransaction(
329 Effect.gen(function* () {410 Effect.gen(function* () {
330 yield* sql`DELETE FROM projection_projects411 yield* sql`DELETE FROM projection_projects
331 WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`;412 WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`;
332 yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions413 yield* sql`DELETE FROM ${sql(PROVIDER_SESSIONS_TABLE)}
333 WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads)414 WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads)
334 AND provider_session_id NOT IN (415 AND provider_session_id NOT IN (
335 SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings416 SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings
...@@ -348,14 +429,9 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra...@@ -348,14 +429,9 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
348 AND stream_id NOT IN (SELECT thread_id FROM kept_threads))429 AND stream_id NOT IN (SELECT thread_id FROM kept_threads))
349 OR (aggregate_kind = 'project'430 OR (aggregate_kind = 'project'
350 AND stream_id NOT IN (SELECT project_id FROM kept_projects))`;431 AND stream_id NOT IN (SELECT project_id FROM kept_projects))`;
351 // Pending work the dev server would otherwise pick up and run.432 for (const table of CLEARED_TABLES) {
352 yield* sql`DELETE FROM scheduled_tasks`;433 yield* sql`DELETE FROM ${sql(table)}`;
353 yield* sql`DELETE FROM orchestration_v2_effect_outbox`;434 }
354 yield* sql`DELETE FROM orchestration_v2_thread_launch_workflows`;
355 yield* sql`DELETE FROM orchestration_command_receipts`;
356 yield* sql`DELETE FROM provider_session_runtime`;
357 yield* sql`DELETE FROM auth_sessions`;
358 yield* sql`DELETE FROM auth_pairing_links`;
359 }),435 }),
360 );436 );
361437
...@@ -457,22 +533,16 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (...@@ -457,22 +533,16 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
457 );533 );
458534
459 yield* removeDatabaseFiles(snapshotPath);535 yield* removeDatabaseFiles(snapshotPath);
460 // The snapshot is a full-size copy of the source; make sure it is removed536 // Make sure the snapshot is removed even when a phase fails partway through.
461 // even when a phase fails partway through.
462 const { executedMigrations, pruned } = yield* Effect.gen(function* () {537 const { executedMigrations, pruned } = yield* Effect.gen(function* () {
463 yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`);538 yield* Console.log(`Copying visible threads from ${sourcePath} (read-only)...`);
464 yield* Effect.gen(function* () {539 yield* copySourceSlice(sourcePath).pipe(
465 const sql = yield* SqlClient.SqlClient;540 Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })),
466 yield* sql`VACUUM INTO ${snapshotPath}`;
467 }).pipe(
468 Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })),
469 wrapPhase("snapshot", sourcePath),541 wrapPhase("snapshot", sourcePath),
470 );542 );
471543
472 // Migrate before pruning: a source older than this checkout would544 // Migrate before pruning: a source older than this checkout would
473 // otherwise crash the prune queries on columns that don't exist yet.545 // otherwise crash the prune queries on columns that don't exist yet.
474 // Running against the full snapshot also exercises new migrations on the
475 // same data volume the real database would face.
476 yield* Console.log("Running migrations on the snapshot...");546 yield* Console.log("Running migrations on the snapshot...");
477 const executed = yield* Effect.gen(function* () {547 const executed = yield* Effect.gen(function* () {
478 const sql = yield* SqlClient.SqlClient;548 const sql = yield* SqlClient.SqlClient;
apps/server/src/mcp/OrchestratorMcpService.ts+6-29
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import {1import {
2 type EnvironmentId,
3 CommandId,2 CommandId,
4 type RunId,3 type RunId,
5 isProviderAvailable,4 isProviderAvailable,
...@@ -59,7 +58,6 @@ import {...@@ -59,7 +58,6 @@ import {
59 ThreadId,58 ThreadId,
60} from "@t3tools/contracts";59} from "@t3tools/contracts";
61import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";60import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
62import { formatThreadLink } from "@t3tools/shared/threadLinks";
63import * as Clock from "effect/Clock";61import * as Clock from "effect/Clock";
64import * as Context from "effect/Context";62import * as Context from "effect/Context";
65import * as Crypto from "effect/Crypto";63import * as Crypto from "effect/Crypto";
...@@ -633,23 +631,12 @@ function threadSnooze(...@@ -633,23 +631,12 @@ function threadSnooze(
633 };631 };
634}632}
635633
636/** Where and when a thread is shown: the environment for its link, and the time for snooze state. */
637interface ThreadViewContext {
638 readonly environmentId: EnvironmentId;
639 readonly nowMs: number;
640}
641
642function listItemFromShell(634function listItemFromShell(
643 shell: OrchestrationV2ThreadShell,635 shell: OrchestrationV2ThreadShell,
644 context: ThreadViewContext,636 nowMs: number,
645): OrchestratorMcpThreadListItem {637): OrchestratorMcpThreadListItem {
646 return {638 return {
647 threadId: shell.id,639 threadId: shell.id,
648 link: formatThreadLink({
649 environmentId: context.environmentId,
650 threadId: shell.id,
651 title: shell.title,
652 }),
653 title: shell.title,640 title: shell.title,
654 createdBy: shell.createdBy,641 createdBy: shell.createdBy,
655 creationSource: shell.creationSource,642 creationSource: shell.creationSource,
...@@ -661,7 +648,7 @@ function listItemFromShell(...@@ -661,7 +648,7 @@ function listItemFromShell(
661 interactionMode: shell.interactionMode,648 interactionMode: shell.interactionMode,
662 linkedPullRequest: shell.linkedPullRequest ?? null,649 linkedPullRequest: shell.linkedPullRequest ?? null,
663 ...threadSettlement(shell),650 ...threadSettlement(shell),
664 ...threadSnooze(shell, context.nowMs),651 ...threadSnooze(shell, nowMs),
665 parentThreadId: shell.lineage.parentThreadId,652 parentThreadId: shell.lineage.parentThreadId,
666 relationshipToParent: shell.lineage.relationshipToParent,653 relationshipToParent: shell.lineage.relationshipToParent,
667 itemCount: shell.visibleItemCount,654 itemCount: shell.visibleItemCount,
...@@ -674,17 +661,12 @@ function threadDetail(...@@ -674,17 +661,12 @@ function threadDetail(
674 projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">,661 projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">,
675 itemCount: number,662 itemCount: number,
676 shell: OrchestrationV2ThreadShell,663 shell: OrchestrationV2ThreadShell,
677 context: ThreadViewContext,664 nowMs: number,
678): OrchestratorMcpThreadDetail {665): OrchestratorMcpThreadDetail {
679 const latest = ThreadManagementService.latestRun(projection);666 const latest = ThreadManagementService.latestRun(projection);
680 const active = ThreadManagementService.latestActiveRun(projection);667 const active = ThreadManagementService.latestActiveRun(projection);
681 return {668 return {
682 threadId: projection.thread.id,669 threadId: projection.thread.id,
683 link: formatThreadLink({
684 environmentId: context.environmentId,
685 threadId: projection.thread.id,
686 title: projection.thread.title,
687 }),
688 projectId: projection.thread.projectId,670 projectId: projection.thread.projectId,
689 title: projection.thread.title,671 title: projection.thread.title,
690 createdBy: projection.thread.createdBy,672 createdBy: projection.thread.createdBy,
...@@ -717,7 +699,7 @@ function threadDetail(...@@ -717,7 +699,7 @@ function threadDetail(
717 archived: projection.thread.archivedAt !== null,699 archived: projection.thread.archivedAt !== null,
718 ...threadSettlement(projection.thread),700 ...threadSettlement(projection.thread),
719 // From the shell, like the list, so read and list agree on snooze state.701 // From the shell, like the list, so read and list agree on snooze state.
720 ...threadSnooze(shell, context.nowMs),702 ...threadSnooze(shell, nowMs),
721 createdAt: DateTime.formatIso(projection.thread.createdAt),703 createdAt: DateTime.formatIso(projection.thread.createdAt),
722 updatedAt: DateTime.formatIso(projection.thread.updatedAt),704 updatedAt: DateTime.formatIso(projection.thread.updatedAt),
723 };705 };
...@@ -2293,9 +2275,7 @@ const make = Effect.gen(function* () {...@@ -2293,9 +2275,7 @@ const make = Effect.gen(function* () {
2293 return {2275 return {
2294 projectId,2276 projectId,
2295 currentThreadId: parent?.thread.id ?? null,2277 currentThreadId: parent?.thread.id ?? null,
2296 threads: page.map((shell) =>2278 threads: page.map((shell) => listItemFromShell(shell, nowMs)),
2297 listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }),
2298 ),
2299 nextCursor,2279 nextCursor,
2300 total: filtered.length,2280 total: filtered.length,
2301 } satisfies OrchestratorMcpThreadListResult;2281 } satisfies OrchestratorMcpThreadListResult;
...@@ -2375,10 +2355,7 @@ const make = Effect.gen(function* () {...@@ -2375,10 +2355,7 @@ const make = Effect.gen(function* () {
2375 }2355 }
2376 }2356 }
2377 return {2357 return {
2378 thread: threadDetail(target, timeline.totalItems, shell, {2358 thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis),
2379 environmentId: scope.environmentId,
2380 nowMs: yield* Clock.currentTimeMillis,
2381 }),
2382 recentRuns: target.runs2359 recentRuns: target.runs
2383 .toSorted((left, right) => right.ordinal - left.ordinal)2360 .toSorted((left, right) => right.ordinal - left.ordinal)
2384 .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)2361 .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts-3
...@@ -2233,9 +2233,6 @@ describe("orchestrator MCP toolkit", () => {...@@ -2233,9 +2233,6 @@ describe("orchestrator MCP toolkit", () => {
2233 });2233 });
22342234
2235 expect(metadataRead.thread).toMatchObject({ snoozed: false, snoozedUntil: null });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({2236 yield* orchestrator.dispatch({
2240 type: "thread.snooze",2237 type: "thread.snooze",
2241 commandId: CommandId.make("command:mcp-empty:snooze"),2238 commandId: CommandId.make("command:mcp-empty:snooze"),
apps/server/src/mcp/toolkits/orchestrator/tools.ts+2-2
...@@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", {...@@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", {
178178
179const ThreadListTool = Tool.make("t3_thread_list", {179const ThreadListTool = Tool.make("t3_thread_list", {
180 description:180 description:
181 "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), 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.",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. To link a thread for the user, write `[title](t3-thread://v1/<threadId>)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.",
182 parameters: OrchestratorMcpThreadListInput,182 parameters: OrchestratorMcpThreadListInput,
183 success: OrchestratorMcpThreadListResult,183 success: OrchestratorMcpThreadListResult,
184 failure: OrchestratorMcpFailure,184 failure: OrchestratorMcpFailure,
...@@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", {...@@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", {
192192
193const ThreadReadTool = Tool.make("t3_thread_read", {193const ThreadReadTool = Tool.make("t3_thread_read", {
194 description:194 description:
195 "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread has a link and snooze state: paste the link when you mention the thread so the user can open it.",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 also reports its snooze state. To link a thread for the user, write `[title](t3-thread://v1/<threadId>)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.",
196 parameters: OrchestratorMcpThreadReadInput,196 parameters: OrchestratorMcpThreadReadInput,
197 success: OrchestratorMcpThreadReadResult,197 success: OrchestratorMcpThreadReadResult,
198 failure: OrchestratorMcpFailure,198 failure: OrchestratorMcpFailure,
apps/server/src/mcp/toolkits/project/handlers.test.ts+3-13
...@@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread",...@@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread",
69 return Effect.succeed({69 return Effect.succeed({
70 threadId: input.threadId,70 threadId: input.threadId,
71 projection: {71 projection: {
72 thread: { id: input.threadId, projectId, modelSelection, title: input.title },72 thread: { id: input.threadId, projectId, modelSelection },
73 runs: [],73 runs: [],
74 },74 },
75 resumed: false,75 resumed: false,
...@@ -94,11 +94,7 @@ it.effect("attributes a launched thread's first message to the calling thread",...@@ -94,11 +94,7 @@ it.effect("attributes a launched thread's first message to the calling thread",
94 const result = yield* toolkit94 const result = yield* toolkit
95 .handle("t3_thread_launch", { title: "Audit", message: "Review the change" })95 .handle("t3_thread_launch", { title: "Audit", message: "Review the change" })
96 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies));96 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies));
97 expect(result.at(-1)?.result).toMatchObject({97 expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection });
98 projectId,
99 modelSelection,
100 link: expect.stringMatching(/^\[Audit\]\(t3-thread:\/\/v1\/environment\//),
101 });
102 expect(launchedSender).toBe(sourceThreadId);98 expect(launchedSender).toBe(sourceThreadId);
103 }),99 }),
104);100);
...@@ -144,12 +140,7 @@ it.effect("launches a scratch thread into the Scratch project", () =>...@@ -144,12 +140,7 @@ it.effect("launches a scratch thread into the Scratch project", () =>
144 return Effect.succeed({140 return Effect.succeed({
145 threadId: input.threadId,141 threadId: input.threadId,
146 projection: {142 projection: {
147 thread: {143 thread: { id: input.threadId, projectId: input.projectId, modelSelection },
148 id: input.threadId,
149 projectId: input.projectId,
150 modelSelection,
151 title: input.title,
152 },
153 runs: [],144 runs: [],
154 },145 },
155 resumed: false,146 resumed: false,
...@@ -338,7 +329,6 @@ const clientLaunchHarness = (input: {...@@ -338,7 +329,6 @@ const clientLaunchHarness = (input: {
338 id: launch.threadId,329 id: launch.threadId,
339 projectId: launch.projectId,330 projectId: launch.projectId,
340 modelSelection: launch.modelSelection,331 modelSelection: launch.modelSelection,
341 title: launch.title,
342 },332 },
343 runs: [],333 runs: [],
344 },334 },
apps/server/src/mcp/toolkits/project/handlers.ts+1-7
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";1import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
2import { formatThreadLink } from "@t3tools/shared/threadLinks";
3import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";4import * as Option from "effect/Option";
...@@ -58,7 +57,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {...@@ -58,7 +57,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
58 (input, { runtimeMode, interactionMode }) =>57 (input, { runtimeMode, interactionMode }) =>
59 Effect.gen(function* () {58 Effect.gen(function* () {
60 const context = yield* readCaller();59 const context = yield* readCaller();
61 const { caller, scope } = context;60 const { caller } = context;
62 const commandId = yield* newCommandId();61 const commandId = yield* newCommandId();
63 const threadId = ThreadId.make(commandId);62 const threadId = ThreadId.make(commandId);
64 const messageId = MessageId.make(commandId);63 const messageId = MessageId.make(commandId);
...@@ -147,11 +146,6 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {...@@ -147,11 +146,6 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
147 const run = result.projection.runs.find((run) => run.userMessageId === messageId);146 const run = result.projection.runs.find((run) => run.userMessageId === messageId);
148 return {147 return {
149 threadId: thread.id,148 threadId: thread.id,
150 link: formatThreadLink({
151 environmentId: scope.environmentId,
152 threadId: thread.id,
153 title: thread.title,
154 }),
155 projectId: thread.projectId,149 projectId: thread.projectId,
156 modelSelection: thread.modelSelection,150 modelSelection: thread.modelSelection,
157 runId: run?.id ?? null,151 runId: run?.id ?? null,
apps/server/src/mcp/toolkits/project/tools.ts+1-3
...@@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {...@@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {
103const ThreadLaunchTool = Tool.make("t3_thread_launch", {103const ThreadLaunchTool = Tool.make("t3_thread_launch", {
104 ...shared,104 ...shared,
105 description:105 description:
106 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. 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.',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. To link a thread for the user, write `[title](t3-thread://v1/<threadId>)` 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.',
107 parameters: Schema.Struct({107 parameters: Schema.Struct({
108 projectId: Schema.optional(ProjectId),108 projectId: Schema.optional(ProjectId),
109 scratch: Schema.optional(109 scratch: Schema.optional(
...@@ -132,8 +132,6 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {...@@ -132,8 +132,6 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
132 }),132 }),
133 success: Schema.Struct({133 success: Schema.Struct({
134 threadId: ThreadId,134 threadId: ThreadId,
135 /** Paste this whenever you mention the thread, so the user can click to open it. */
136 link: Schema.String,
137 projectId: ProjectId,135 projectId: ProjectId,
138 modelSelection: ModelSelection,136 modelSelection: ModelSelection,
139 runId: Schema.NullOr(RunId),137 runId: Schema.NullOr(RunId),
apps/server/src/provider/T3OrchestrationInstructions.ts+1
...@@ -11,6 +11,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept...@@ -11,6 +11,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept
11- 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.11- 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.
12- \`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.12- \`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.
13- 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.13- 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.
14- To mention another thread to the user, link it as \`[title](t3-thread://v1/<threadId>)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title.
1415
15### Choose the workspace before starting a new thread16### Choose the workspace before starting a new thread
1617
apps/web/src/components/ChatMarkdown.tsx+8-7
...@@ -3167,13 +3167,14 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3167,13 +3167,14 @@ const CHAT_MARKDOWN_COMPONENTS = {
3167 } = use(ChatMarkdownRendererContext);3167 } = use(ChatMarkdownRendererContext);
3168 const citation = href ? parseAssistantCitationHref(href) : null;3168 const citation = href ? parseAssistantCitationHref(href) : null;
3169 if (citation) return <AssistantCitationChip citation={citation} />;3169 if (citation) return <AssistantCitationChip citation={citation} />;
3170 // A thread link opens the thread here, never a browser.3170 // A thread link names a thread in this message's environment and opens it in the app.
3171 const threadLink = href ? parseThreadLinkHref(href) : null;3171 const linkedThreadId = href ? parseThreadLinkHref(href) : null;
3172 if (threadLink) {3172 if (linkedThreadId) {
3173 return (3173 const label = hastPlainTextDeep(node) || linkedThreadId;
3174 <MarkdownThreadLink {...threadLink}>3174 return environmentId ? (
3175 <MarkdownLinkContext value>{children}</MarkdownLinkContext>3175 <MarkdownThreadLink environmentId={environmentId} threadId={linkedThreadId} label={label} />
3176 </MarkdownThreadLink>3176 ) : (
3177 <span>{label}</span>
3177 );3178 );
3178 }3179 }
3179 const contextReference = href ? parseComposerContextHref(href) : null;3180 const contextReference = href ? parseComposerContextHref(href) : null;
apps/web/src/components/chat/MarkdownThreadLink.tsx+19-9
...@@ -1,31 +1,41 @@...@@ -1,31 +1,41 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { formatThreadLink, percentDecodedThreadLinkId } from "@t3tools/shared/threadLinks";
3import { Link } from "@tanstack/react-router";4import { Link } from "@tanstack/react-router";
4import { MessageSquareTextIcon } from "lucide-react";5import { MessageSquareTextIcon } from "lucide-react";
5import type { ReactNode } from "react";
66
7import { useProject, useThreadShell } from "../../state/entities";7import { useProject, useThreadShell } from "../../state/entities";
8import { ProjectFavicon } from "../ProjectFavicon";8import { ProjectFavicon } from "../ProjectFavicon";
99
10/**10/**
11 * A `t3-thread://` link in chat. It leads with the thread's project icon, like11 * A `t3-thread://` link in chat. It shows the thread's current title, so a rename
12 * a web link leads with its favicon, so it reads as a thread link before you12 * reaches every message that links to it, and leads with the thread's project icon
13 * hover it. Opens the thread in the app.13 * the way a web link leads with its favicon. `label` is what the message wrote,
14 * shown only when this client cannot see the thread. Opens the thread in the app.
14 */15 */
15export function MarkdownThreadLink(props: {16export function MarkdownThreadLink(props: {
16 readonly environmentId: EnvironmentId;17 readonly environmentId: EnvironmentId;
17 readonly threadId: ThreadId;18 readonly threadId: ThreadId;
18 readonly children: ReactNode;19 readonly label: string;
19}) {20}) {
20 const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId));21 const decodedId = percentDecodedThreadLinkId(props.threadId);
22 const written = useThreadShell(scopeThreadRef(props.environmentId, props.threadId));
23 const decoded = useThreadShell(
24 written === null && decodedId !== null ? scopeThreadRef(props.environmentId, decodedId) : null,
25 );
26 const thread = written ?? decoded;
27 const threadId = thread?.id ?? props.threadId;
21 const project = useProject(28 const project = useProject(
22 thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId),29 thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId),
23 );30 );
31 const title = thread?.title.trim() || props.label;
24 return (32 return (
25 <Link33 <Link
26 to="/$environmentId/$threadId"34 to="/$environmentId/$threadId"
27 params={{ environmentId: props.environmentId, threadId: props.threadId }}35 params={{ environmentId: props.environmentId, threadId }}
28 title={project?.title}36 // Like an attached thread chip: archived threads are not in the index but still open.
37 title={thread === null ? "Thread no longer available" : project?.title}
38 data-markdown-copy={formatThreadLink(threadId, title)}
29 >39 >
30 <span40 <span
31 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"41 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
...@@ -37,7 +47,7 @@ export function MarkdownThreadLink(props: {...@@ -37,7 +47,7 @@ export function MarkdownThreadLink(props: {
37 <ProjectFavicon project={project} className="size-full" />47 <ProjectFavicon project={project} className="size-full" />
38 )}48 )}
39 </span>49 </span>
40 {props.children}50 {title}
41 </Link>51 </Link>
42 );52 );
43}53}
docs/orchestration-v2/orchestrator-mcp-server.md+6-3
...@@ -352,9 +352,12 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`....@@ -352,9 +352,12 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`.
352Actor and ingress are separate so agent-authored user-role messages remain352Actor and ingress are separate so agent-authored user-role messages remain
353distinguishable from human-authored messages.353distinguishable from human-authored messages.
354354
355List, read, and launch results include `link`, a Markdown link of the form355Agents mention another thread as `[title](t3-thread://v1/<threadId>)`. The
356`[title](t3-thread://v1/<environmentId>/<threadId>)` that clients open as the356link carries only the id, which resolves in the environment of the message that
357thread. List and read results also report `snoozed` and `snoozedUntil`, and357holds it. Clients show the thread's current title rather than the label, so a
358rename never leaves a stale link.
359
360List and read results report `snoozed` and `snoozedUntil`, and
358`t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the361`t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the
359client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread362client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread
360wakes early when it has a pending request, fails, or completes after the snooze.363wakes early when it has a pending request, fails, or completes after the snooze.
packages/contracts/src/orchestratorMcp.ts-4
...@@ -304,8 +304,6 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp...@@ -304,8 +304,6 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp
304304
305export const OrchestratorMcpThreadListItem = Schema.Struct({305export const OrchestratorMcpThreadListItem = Schema.Struct({
306 threadId: ThreadId,306 threadId: ThreadId,
307 /** Paste this whenever you mention the thread, so the user can click to open it. */
308 link: Schema.String,
309 title: Schema.String,307 title: Schema.String,
310 createdBy: OrchestrationV2Actor,308 createdBy: OrchestrationV2Actor,
311 creationSource: OrchestrationV2CreationSource,309 creationSource: OrchestrationV2CreationSource,
...@@ -353,8 +351,6 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp...@@ -353,8 +351,6 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp
353351
354export const OrchestratorMcpThreadDetail = Schema.Struct({352export const OrchestratorMcpThreadDetail = Schema.Struct({
355 threadId: ThreadId,353 threadId: ThreadId,
356 /** Paste this whenever you mention the thread, so the user can click to open it. */
357 link: Schema.String,
358 projectId: ProjectId,354 projectId: ProjectId,
359 title: Schema.String,355 title: Schema.String,
360 createdBy: OrchestrationV2Actor,356 createdBy: OrchestrationV2Actor,
packages/shared/src/threadLinks.test.ts+66-19
...@@ -1,28 +1,75 @@...@@ -1,28 +1,75 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts";3import { formatThreadLink, parseThreadLinkHref, relabelThreadLinks } from "./threadLinks.ts";
44
5describe("thread links", () => {5describe("thread links", () => {
6 it("round-trips ids that contain URL characters", () => {6 it("takes the thread id verbatim, percent escapes included", () => {
7 const link = formatThreadLink({7 expect(parseThreadLinkHref("t3-thread://v1/mcp:1234")).toBe("mcp:1234");
8 environmentId: "studio mac",8 expect(parseThreadLinkHref("t3-thread://v1/thread:delegated-task:mcp%3A1")).toBe(
9 threadId: "mcp:(1)/2",9 "thread:delegated-task:mcp%3A1",
10 title: "Fix [the] build\nnow",10 );
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 });11 });
2112
22 it("rejects other links and malformed ones", () => {13 it("rejects other links and an empty id", () => {
23 expect(parseThreadLinkHref("https://t3.codes")).toBeNull();14 expect(parseThreadLinkHref("https://t3.codes")).toBeNull();
24 expect(parseThreadLinkHref("t3-thread://v1/only-one")).toBeNull();15 expect(parseThreadLinkHref("t3-thread://v1/")).toBeNull();
25 expect(parseThreadLinkHref("t3-thread://v1/env/%E0%A4%A")).toBeNull();16 expect(parseThreadLinkHref("t3-thread://v1/ ")).toBeNull();
26 expect(parseThreadLinkHref("t3-thread://v1/%20/thread")).toBeNull();17 });
18
19 it("resolves a percent-encoded id when the id as written names no thread", () => {
20 const titles = new Map([
21 ["thread:project:1", "Decoded"],
22 ["provider%3A1", "Literal escape"],
23 ]);
24 expect(
25 relabelThreadLinks(
26 "[a](t3-thread://v1/thread%3Aproject%3A1) [b](t3-thread://v1/provider%3A1)",
27 (threadId) => titles.get(threadId),
28 ),
29 ).toBe(
30 "[Decoded](t3-thread://v1/thread:project:1) [Literal escape](t3-thread://v1/provider%3A1)",
31 );
32 // A thread with an empty title still exists, so its link is not redirected.
33 const untitled = new Map([
34 ["a%3A1", ""],
35 ["a:1", "Other"],
36 ]);
37 expect(
38 relabelThreadLinks("[Kept](t3-thread://v1/a%3A1)", (threadId) => untitled.get(threadId)),
39 ).toBe("[Kept](t3-thread://v1/a%3A1)");
40 });
41
42 it("leaves links inside code spans and fences as written", () => {
43 const markdown = [
44 "Live [old](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.",
45 "```md",
46 "[old](t3-thread://v1/t1)",
47 "```",
48 "After [old](t3-thread://v1/t1)",
49 ].join("\n");
50 expect(relabelThreadLinks(markdown, () => "New")).toBe(
51 [
52 "Live [New](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.",
53 "```md",
54 "[old](t3-thread://v1/t1)",
55 "```",
56 "After [New](t3-thread://v1/t1)",
57 ].join("\n"),
58 );
59 });
60
61 it("formats a label that would otherwise break the Markdown link", () => {
62 expect(formatThreadLink("t1", "Fix [ci] \\ build")).toBe("[Fix ci build](t3-thread://v1/t1)");
63 expect(formatThreadLink("t1", " ] ")).toBe("[t1](t3-thread://v1/t1)");
64 });
65
66 it("relabels links with the current title and leaves unknown threads alone", () => {
67 const titles = new Map([["renamed", "Fix [the] build\nnow"]]);
68 expect(
69 relabelThreadLinks(
70 "See [Old name](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted).",
71 (threadId) => titles.get(threadId),
72 ),
73 ).toBe("See [Fix the build now](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted).");
27 });74 });
28});75});
packages/shared/src/threadLinks.ts+56-41
...@@ -1,58 +1,73 @@...@@ -1,58 +1,73 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";1import { ThreadId } from "@t3tools/contracts";
2import * as Option from "effect/Option";2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
44
5/**5/**
6 * In-app links to threads. Agents put them in Markdown as6 * Agents mention another thread in Markdown as `[title](t3-thread://v1/<threadId>)`. The id is
7 * `[title](t3-thread://v1/<environmentId>/<threadId>)`, and clients open the7 * the reference and resolves in the environment of the message that holds it. Titles change, so
8 * thread instead of a browser. Thread tools return a ready `link` for each8 * clients show the thread's current title; the label only stands in for a thread they cannot see.
9 * thread, so agents never build one by hand.
10 */9 */
11export const THREAD_LINK_PROTOCOL = "t3-thread";10export const THREAD_LINK_PROTOCOL = "t3-thread";
12const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`;11const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`;
13const LINK_LABEL_MAX_CHARS = 120;12// Code comes first in the alternation so a link written inside a code span or fence is skipped.
13const THREAD_LINK_OUTSIDE_CODE =
14 /(?<fence>(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?<span>(`+)[^\n]*?\4)|\[[^\]\n]*\]\((?<href>t3-thread:\/\/v1\/[^\s)]+)\)/g;
1415
15const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId);
16const decodeThreadId = Schema.decodeUnknownOption(ThreadId);16const decodeThreadId = Schema.decodeUnknownOption(ThreadId);
1717
18// encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early.18/** The id as written. Thread ids can hold percent escapes of their own, so it is not decoded. */
19function encodeIdSegment(id: string): string {19export function parseThreadLinkHref(href: string): ThreadId | null {
20 return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29");20 if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null;
21}21 return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length)));
22
23function formatThreadLinkHref(environmentId: string, threadId: string): string {
24 return `${THREAD_LINK_HREF_PREFIX}${encodeIdSegment(environmentId)}/${encodeIdSegment(threadId)}`;
25}
26
27/** A Markdown link to the thread, labeled with its title. */
28export function formatThreadLink(thread: {
29 readonly environmentId: string;
30 readonly threadId: string;
31 readonly title: string;
32}): string {
33 const label =
34 thread.title
35 .replace(/[[\]\\\r\n]/g, " ")
36 .replace(/\s+/g, " ")
37 .trim()
38 .slice(0, LINK_LABEL_MAX_CHARS) || "Untitled thread";
39 return `[${label}](${formatThreadLinkHref(thread.environmentId, thread.threadId)})`;
40}22}
4123
42export function parseThreadLinkHref(24/**
43 href: string,25 * Agents often percent-encode the id anyway. When the id as written names no thread, clients try
44): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null {26 * this decoded form. Null when decoding changes nothing or fails.
45 if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null;27 */
46 const parts = href.slice(THREAD_LINK_HREF_PREFIX.length).split("/");28export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null {
47 if (parts.length !== 2) return null;
48 try {29 try {
49 const environmentId = decodeEnvironmentId(decodeURIComponent(parts[0]!));30 const decoded = decodeURIComponent(threadId);
50 const threadId = decodeThreadId(decodeURIComponent(parts[1]!));31 return decoded === threadId ? null : Option.getOrNull(decodeThreadId(decoded));
51 return Option.isSome(environmentId) && Option.isSome(threadId)
52 ? { environmentId: environmentId.value, threadId: threadId.value }
53 : null;
54 } catch {32 } catch {
55 // Malformed percent encoding.
56 return null;33 return null;
57 }34 }
58}35}
36
37/** A thread link whose label survives Markdown: no brackets, backslashes, or line breaks. */
38export function formatThreadLink(threadId: string, label: string): string {
39 const cleaned = label
40 .replace(/[[\]\\\r\n]/g, " ")
41 .replace(/\s+/g, " ")
42 .trim();
43 return `[${cleaned || threadId}](${THREAD_LINK_HREF_PREFIX}${threadId})`;
44}
45
46export function hasThreadLinks(markdown: string): boolean {
47 return markdown.includes(`](${THREAD_LINK_HREF_PREFIX}`);
48}
49
50/**
51 * Relabels each thread link with `title(threadId)`, pointing it at the thread that title came from.
52 * A link it returns nothing for keeps its label.
53 */
54export function relabelThreadLinks(
55 markdown: string,
56 title: (threadId: ThreadId) => string | undefined,
57): string {
58 if (!hasThreadLinks(markdown)) return markdown;
59 return markdown.replace(THREAD_LINK_OUTSIDE_CODE, (source, ...args) => {
60 const href = (args.at(-1) as { href?: string }).href;
61 if (href === undefined) return source;
62 const written = parseThreadLinkHref(href);
63 if (written === null) return source;
64 // The decoded id only stands in when the id as written names no thread.
65 const decoded = percentDecodedThreadLinkId(written);
66 const threadId =
67 title(written) === undefined && decoded !== null && title(decoded) !== undefined
68 ? decoded
69 : written;
70 const label = title(threadId)?.trim();
71 return label ? formatThreadLink(threadId, label) : source;
72 });
73}