| author | |
| committer | |
| log | 0f09627a111f7e9adc22c256c725565672676f1a |
| tree | f7bd5ab7bea6ef95fb848b3aefcc2600386f59d5 |
| parent | 04ad17425a80484bf323a7d5076bd477a9499119 |
| signature | Signed by PGP key B5690EEEBB952194 |
18 files changed, 384 insertions(+), 180 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+20-10| ... | ... | @@ -198,6 +198,7 @@ import { |
| 198 | 198 | } from "../../state/assets"; |
| 199 | 199 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 200 | 200 | import { usePreparedConnection } from "../../state/session"; |
| 201 | import { useLiveThreadLinkLabels } from "../../state/entities"; | |
| 201 | 202 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 202 | 203 | import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; |
| 203 | 204 | import * as Option from "effect/Option"; |
| ... | ... | @@ -906,16 +907,15 @@ interface MarkdownLinkHandlers { |
| 906 | 907 | |
| 907 | 908 | const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 908 | 909 | readonly markdown: string; |
| 910 | readonly environmentId: EnvironmentId; | |
| 909 | 911 | readonly markdownStyles: MarkdownStyleSet; |
| 910 | 912 | readonly linkHandlers: MarkdownLinkHandlers; |
| 911 | 913 | readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; |
| 912 | 914 | readonly renderImage: MarkdownImageRenderer; |
| 913 | 915 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined; |
| 914 | 916 | }) { |
| 915 | const segments = useMemo( | |
| 916 | () => splitCodexArtifactTemplateMarkdown(props.markdown), | |
| 917 | [props.markdown], | |
| 918 | ); | |
| 917 | const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); | |
| 918 | const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]); | |
| 919 | 919 | |
| 920 | 920 | return segments.map((segment) => { |
| 921 | 921 | if (segment.kind === "artifact-template") { |
| ... | ... | @@ -1905,6 +1905,7 @@ function renderFeedEntry( |
| 1905 | 1905 | <MarkdownImageAvailableWidthContext value={props.markdownContentWidth}> |
| 1906 | 1906 | <AssistantMarkdownContent |
| 1907 | 1907 | markdown={renderedText} |
| 1908 | environmentId={props.environmentId} | |
| 1908 | 1909 | markdownStyles={styles} |
| 1909 | 1910 | linkHandlers={props.markdownLinkHandlers} |
| 1910 | 1911 | onUseArtifactTemplate={props.onUseArtifactTemplate} |
| ... | ... | @@ -2002,7 +2003,8 @@ function UserMessageContent(props: UserMessageContentProps) { |
| 2002 | 2003 | const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null); |
| 2003 | 2004 | const navigation = useNavigation(); |
| 2004 | 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 | 2008 | const available = props.context?.records.some((record) => record.contextId === ref.contextId); |
| 2007 | 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 | 2294 | const userBubbleColor = theme["--color-user-bubble"]; |
| 2293 | 2295 | const onMarkdownLinkPress = useCallback( |
| 2294 | 2296 | (href: string) => { |
| 2295 | const threadLink = parseThreadLinkHref(href); | |
| 2296 | if (threadLink) { | |
| 2297 | // A thread link names a thread in this feed's environment. | |
| 2298 | const linkedThreadId = parseThreadLinkHref(href); | |
| 2299 | if (linkedThreadId) { | |
| 2297 | 2300 | navigation.navigate("Thread", { |
| 2298 | environmentId: String(threadLink.environmentId), | |
| 2299 | threadId: String(threadLink.threadId), | |
| 2301 | environmentId: String(props.environmentId), | |
| 2302 | threadId: String(linkedThreadId), | |
| 2300 | 2303 | }); |
| 2301 | 2304 | return; |
| 2302 | 2305 | } |
| ... | ... | @@ -2504,13 +2507,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2504 | 2507 | (text: string) => ( |
| 2505 | 2508 | <AssistantMarkdownContent |
| 2506 | 2509 | markdown={text} |
| 2510 | environmentId={props.environmentId} | |
| 2507 | 2511 | markdownStyles={markdownStyles.assistant} |
| 2508 | 2512 | linkHandlers={markdownLinkHandlers} |
| 2509 | 2513 | renderImage={renderMarkdownImage} |
| 2510 | 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 | 2525 | const reviewCommentColors = useReviewCommentColors(); |
| 2516 | 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 | 91 | children: [ |
| 92 | 92 | { |
| 93 | 93 | type: "link", |
| 94 | href: "t3-thread://v1/env/thread-1", | |
| 94 | href: "t3-thread://v1/thread-1", | |
| 95 | 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 | }); |
| 101 | 101 | |
| 102 | 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 | 12 | ScopedProjectRef, |
| 13 | 13 | ScopedThreadRef, |
| 14 | 14 | ServerConfig, |
| 15 | ThreadId, | |
| 15 | 16 | } from "@t3tools/contracts"; |
| 17 | import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks"; | |
| 16 | 18 | import { Atom } from "effect/reactivity"; |
| 19 | import { useMemo } from "react"; | |
| 17 | 20 | |
| 18 | 21 | import { environmentProjects } from "./projects"; |
| 19 | 22 | import { environmentServerConfigsAtom, serverEnvironment } from "./server"; |
| ... | ... | @@ -28,6 +31,25 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p |
| 28 | 31 | const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe( |
| 29 | 32 | Atom.withLabel("mobile-server-config:empty"), |
| 30 | 33 | ); |
| 34 | const EMPTY_THREAD_TITLES: ReadonlyMap<ThreadId, string> = new Map(); | |
| 35 | const 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. */ | |
| 40 | const 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 | }); | |
| 31 | 53 | |
| 32 | 54 | /** Resolves when the project event reaches the live client store. */ |
| 33 | 55 | export function waitForProject( |
| ... | ... | @@ -76,6 +98,20 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh |
| 76 | 98 | ); |
| 77 | 99 | } |
| 78 | 100 | |
| 101 | /** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ | |
| 102 | export 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 | ||
| 79 | 115 | export function useEnvironmentServerConfig( |
| 80 | 116 | environmentId: EnvironmentId | null, |
| 81 | 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 | 142 | }), |
| 143 | 143 | ); |
| 144 | 144 | |
| 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 | 204 | it.effect("fails loudly on a migration slot collision", () => |
| 146 | 205 | Effect.gen(function* () { |
| 147 | 206 | const fs = yield* FileSystem.FileSystem; |
apps/server/scripts/migrate-dev-db.ts+98-28| ... | ... | @@ -6,9 +6,12 @@ |
| 6 | 6 | * |
| 7 | 7 | * `vp run migrate-dev-db` from a worktree: |
| 8 | 8 | * 1. Nukes `<worktree>/.t3/userdata/statev2.sqlite`. |
| 9 | * 2. Snapshots the real db (`~/.t3/userdata/statev2.sqlite`, read-only | |
| 10 | * VACUUM INTO) and prunes it to the most recently updated projects and, | |
| 11 | * per project, the most recent threads that have fully stopped, with | |
| 9 | * 2. Copies a slice of the real db (`~/.t3/userdata/statev2.sqlite`, | |
| 10 | * attached read-only): the schema, and every row except those of | |
| 11 | * 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 | 15 | * their forks and subagents. Working, settled, and archived threads, and |
| 13 | 16 | * threads with pending recovery, are skipped, and scheduled tasks and |
| 14 | 17 | * queued effects are dropped, so the dev server never adopts live work. |
| ... | ... | @@ -28,6 +31,7 @@ |
| 28 | 31 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 29 | 32 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 30 | 33 | import * as NodeOS from "node:os"; |
| 34 | import * as NodeURL from "node:url"; | |
| 31 | 35 | import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; |
| 32 | 36 | import * as Console from "effect/Console"; |
| 33 | 37 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -242,6 +246,87 @@ const RECOVERY_KINDS: ReadonlyArray<ProjectionStore.ProjectionRecoveryKind> = [ |
| 242 | 246 | "delegated-completions", |
| 243 | 247 | ]; |
| 244 | 248 | |
| 249 | /** Tables the prune empties. The copy skips their rows. */ | |
| 250 | const 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. */ | |
| 262 | const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions"; | |
| 263 | ||
| 264 | /** Whether the thread aliased `root` is shown: not deleted, archived, or settled. */ | |
| 265 | const 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 | */ | |
| 279 | const 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 | ||
| 245 | 330 | const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { |
| 246 | 331 | const sql = yield* SqlClient.SqlClient; |
| 247 | 332 | |
| ... | ... | @@ -278,11 +363,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra |
| 278 | 363 | SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at |
| 279 | 364 | FROM thread_families f |
| 280 | 365 | JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id |
| 281 | WHERE root.deleted_at IS NULL | |
| 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' | |
| 366 | WHERE ${rootIsVisible(sql)} | |
| 286 | 367 | GROUP BY f.root_id, root.project_id |
| 287 | 368 | HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`; |
| 288 | 369 | |
| ... | ... | @@ -323,13 +404,13 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra |
| 323 | 404 | const threadTables = yield* sql<{ name: string }>` |
| 324 | 405 | SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c |
| 325 | 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}`; | |
| 327 | 408 | |
| 328 | 409 | yield* sql.withTransaction( |
| 329 | 410 | Effect.gen(function* () { |
| 330 | 411 | yield* sql`DELETE FROM projection_projects |
| 331 | 412 | WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`; |
| 332 | yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions | |
| 413 | yield* sql`DELETE FROM ${sql(PROVIDER_SESSIONS_TABLE)} | |
| 333 | 414 | WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads) |
| 334 | 415 | AND provider_session_id NOT IN ( |
| 335 | 416 | SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings |
| ... | ... | @@ -348,14 +429,9 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra |
| 348 | 429 | AND stream_id NOT IN (SELECT thread_id FROM kept_threads)) |
| 349 | 430 | OR (aggregate_kind = 'project' |
| 350 | 431 | AND stream_id NOT IN (SELECT project_id FROM kept_projects))`; |
| 351 | // Pending work the dev server would otherwise pick up and run. | |
| 352 | yield* sql`DELETE FROM scheduled_tasks`; | |
| 353 | yield* sql`DELETE FROM orchestration_v2_effect_outbox`; | |
| 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`; | |
| 432 | for (const table of CLEARED_TABLES) { | |
| 433 | yield* sql`DELETE FROM ${sql(table)}`; | |
| 434 | } | |
| 359 | 435 | }), |
| 360 | 436 | ); |
| 361 | 437 | |
| ... | ... | @@ -457,22 +533,16 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( |
| 457 | 533 | ); |
| 458 | 534 | |
| 459 | 535 | yield* removeDatabaseFiles(snapshotPath); |
| 460 | // The snapshot is a full-size copy of the source; make sure it is removed | |
| 461 | // even when a phase fails partway through. | |
| 536 | // Make sure the snapshot is removed even when a phase fails partway through. | |
| 462 | 537 | const { executedMigrations, pruned } = yield* Effect.gen(function* () { |
| 463 | yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`); | |
| 464 | yield* Effect.gen(function* () { | |
| 465 | const sql = yield* SqlClient.SqlClient; | |
| 466 | yield* sql`VACUUM INTO ${snapshotPath}`; | |
| 467 | }).pipe( | |
| 468 | Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })), | |
| 538 | yield* Console.log(`Copying visible threads from ${sourcePath} (read-only)...`); | |
| 539 | yield* copySourceSlice(sourcePath).pipe( | |
| 540 | Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), | |
| 469 | 541 | wrapPhase("snapshot", sourcePath), |
| 470 | 542 | ); |
| 471 | 543 | |
| 472 | 544 | // Migrate before pruning: a source older than this checkout would |
| 473 | 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 | 546 | yield* Console.log("Running migrations on the snapshot..."); |
| 477 | 547 | const executed = yield* Effect.gen(function* () { |
| 478 | 548 | const sql = yield* SqlClient.SqlClient; |
apps/server/src/mcp/OrchestratorMcpService.ts+6-29| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 1 | import { |
| 2 | type EnvironmentId, | |
| 3 | 2 | CommandId, |
| 4 | 3 | type RunId, |
| 5 | 4 | isProviderAvailable, |
| ... | ... | @@ -59,7 +58,6 @@ import { |
| 59 | 58 | ThreadId, |
| 60 | 59 | } from "@t3tools/contracts"; |
| 61 | 60 | import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; |
| 62 | import { formatThreadLink } from "@t3tools/shared/threadLinks"; | |
| 63 | 61 | import * as Clock from "effect/Clock"; |
| 64 | 62 | import * as Context from "effect/Context"; |
| 65 | 63 | import * as Crypto from "effect/Crypto"; |
| ... | ... | @@ -633,23 +631,12 @@ function threadSnooze( |
| 633 | 631 | }; |
| 634 | 632 | } |
| 635 | 633 | |
| 636 | /** Where and when a thread is shown: the environment for its link, and the time for snooze state. */ | |
| 637 | interface ThreadViewContext { | |
| 638 | readonly environmentId: EnvironmentId; | |
| 639 | readonly nowMs: number; | |
| 640 | } | |
| 641 | ||
| 642 | 634 | function listItemFromShell( |
| 643 | 635 | shell: OrchestrationV2ThreadShell, |
| 644 | context: ThreadViewContext, | |
| 636 | nowMs: number, | |
| 645 | 637 | ): OrchestratorMcpThreadListItem { |
| 646 | 638 | return { |
| 647 | 639 | threadId: shell.id, |
| 648 | link: formatThreadLink({ | |
| 649 | environmentId: context.environmentId, | |
| 650 | threadId: shell.id, | |
| 651 | title: shell.title, | |
| 652 | }), | |
| 653 | 640 | title: shell.title, |
| 654 | 641 | createdBy: shell.createdBy, |
| 655 | 642 | creationSource: shell.creationSource, |
| ... | ... | @@ -661,7 +648,7 @@ function listItemFromShell( |
| 661 | 648 | interactionMode: shell.interactionMode, |
| 662 | 649 | linkedPullRequest: shell.linkedPullRequest ?? null, |
| 663 | 650 | ...threadSettlement(shell), |
| 664 | ...threadSnooze(shell, context.nowMs), | |
| 651 | ...threadSnooze(shell, nowMs), | |
| 665 | 652 | parentThreadId: shell.lineage.parentThreadId, |
| 666 | 653 | relationshipToParent: shell.lineage.relationshipToParent, |
| 667 | 654 | itemCount: shell.visibleItemCount, |
| ... | ... | @@ -674,17 +661,12 @@ function threadDetail( |
| 674 | 661 | projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">, |
| 675 | 662 | itemCount: number, |
| 676 | 663 | shell: OrchestrationV2ThreadShell, |
| 677 | context: ThreadViewContext, | |
| 664 | nowMs: number, | |
| 678 | 665 | ): OrchestratorMcpThreadDetail { |
| 679 | 666 | const latest = ThreadManagementService.latestRun(projection); |
| 680 | 667 | const active = ThreadManagementService.latestActiveRun(projection); |
| 681 | 668 | return { |
| 682 | 669 | threadId: projection.thread.id, |
| 683 | link: formatThreadLink({ | |
| 684 | environmentId: context.environmentId, | |
| 685 | threadId: projection.thread.id, | |
| 686 | title: projection.thread.title, | |
| 687 | }), | |
| 688 | 670 | projectId: projection.thread.projectId, |
| 689 | 671 | title: projection.thread.title, |
| 690 | 672 | createdBy: projection.thread.createdBy, |
| ... | ... | @@ -717,7 +699,7 @@ function threadDetail( |
| 717 | 699 | archived: projection.thread.archivedAt !== null, |
| 718 | 700 | ...threadSettlement(projection.thread), |
| 719 | 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 | 703 | createdAt: DateTime.formatIso(projection.thread.createdAt), |
| 722 | 704 | updatedAt: DateTime.formatIso(projection.thread.updatedAt), |
| 723 | 705 | }; |
| ... | ... | @@ -2293,9 +2275,7 @@ const make = Effect.gen(function* () { |
| 2293 | 2275 | return { |
| 2294 | 2276 | projectId, |
| 2295 | 2277 | currentThreadId: parent?.thread.id ?? null, |
| 2296 | threads: page.map((shell) => | |
| 2297 | listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }), | |
| 2298 | ), | |
| 2278 | threads: page.map((shell) => listItemFromShell(shell, nowMs)), | |
| 2299 | 2279 | nextCursor, |
| 2300 | 2280 | total: filtered.length, |
| 2301 | 2281 | } satisfies OrchestratorMcpThreadListResult; |
| ... | ... | @@ -2375,10 +2355,7 @@ const make = Effect.gen(function* () { |
| 2375 | 2355 | } |
| 2376 | 2356 | } |
| 2377 | 2357 | return { |
| 2378 | thread: threadDetail(target, timeline.totalItems, shell, { | |
| 2379 | environmentId: scope.environmentId, | |
| 2380 | nowMs: yield* Clock.currentTimeMillis, | |
| 2381 | }), | |
| 2358 | thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis), | |
| 2382 | 2359 | recentRuns: target.runs |
| 2383 | 2360 | .toSorted((left, right) => right.ordinal - left.ordinal) |
| 2384 | 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 | 2233 | }); |
| 2234 | 2234 | |
| 2235 | 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 | 2236 | yield* orchestrator.dispatch({ |
| 2240 | 2237 | type: "thread.snooze", |
| 2241 | 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 | 178 | |
| 179 | 179 | const ThreadListTool = Tool.make("t3_thread_list", { |
| 180 | 180 | description: |
| 181 | "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), 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 | 182 | parameters: OrchestratorMcpThreadListInput, |
| 183 | 183 | success: OrchestratorMcpThreadListResult, |
| 184 | 184 | failure: OrchestratorMcpFailure, |
| ... | ... | @@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { |
| 192 | 192 | |
| 193 | 193 | const ThreadReadTool = Tool.make("t3_thread_read", { |
| 194 | 194 | description: |
| 195 | "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. 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 | 196 | parameters: OrchestratorMcpThreadReadInput, |
| 197 | 197 | success: OrchestratorMcpThreadReadResult, |
| 198 | 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 | 69 | return Effect.succeed({ |
| 70 | 70 | threadId: input.threadId, |
| 71 | 71 | projection: { |
| 72 | thread: { id: input.threadId, projectId, modelSelection, title: input.title }, | |
| 72 | thread: { id: input.threadId, projectId, modelSelection }, | |
| 73 | 73 | runs: [], |
| 74 | 74 | }, |
| 75 | 75 | resumed: false, |
| ... | ... | @@ -94,11 +94,7 @@ it.effect("attributes a launched thread's first message to the calling thread", |
| 94 | 94 | const result = yield* toolkit |
| 95 | 95 | .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) |
| 96 | 96 | .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); |
| 97 | expect(result.at(-1)?.result).toMatchObject({ | |
| 98 | projectId, | |
| 99 | modelSelection, | |
| 100 | link: expect.stringMatching(/^\[Audit\]\(t3-thread:\/\/v1\/environment\//), | |
| 101 | }); | |
| 97 | expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); | |
| 102 | 98 | expect(launchedSender).toBe(sourceThreadId); |
| 103 | 99 | }), |
| 104 | 100 | ); |
| ... | ... | @@ -144,12 +140,7 @@ it.effect("launches a scratch thread into the Scratch project", () => |
| 144 | 140 | return Effect.succeed({ |
| 145 | 141 | threadId: input.threadId, |
| 146 | 142 | projection: { |
| 147 | thread: { | |
| 148 | id: input.threadId, | |
| 149 | projectId: input.projectId, | |
| 150 | modelSelection, | |
| 151 | title: input.title, | |
| 152 | }, | |
| 143 | thread: { id: input.threadId, projectId: input.projectId, modelSelection }, | |
| 153 | 144 | runs: [], |
| 154 | 145 | }, |
| 155 | 146 | resumed: false, |
| ... | ... | @@ -338,7 +329,6 @@ const clientLaunchHarness = (input: { |
| 338 | 329 | id: launch.threadId, |
| 339 | 330 | projectId: launch.projectId, |
| 340 | 331 | modelSelection: launch.modelSelection, |
| 341 | title: launch.title, | |
| 342 | 332 | }, |
| 343 | 333 | runs: [], |
| 344 | 334 | }, |
apps/server/src/mcp/toolkits/project/handlers.ts+1-7| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 1 | import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; |
| 2 | import { formatThreadLink } from "@t3tools/shared/threadLinks"; | |
| 3 | 2 | import * as Effect from "effect/Effect"; |
| 4 | 3 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 4 | import * as Option from "effect/Option"; |
| ... | ... | @@ -58,7 +57,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { |
| 58 | 57 | (input, { runtimeMode, interactionMode }) => |
| 59 | 58 | Effect.gen(function* () { |
| 60 | 59 | const context = yield* readCaller(); |
| 61 | const { caller, scope } = context; | |
| 60 | const { caller } = context; | |
| 62 | 61 | const commandId = yield* newCommandId(); |
| 63 | 62 | const threadId = ThreadId.make(commandId); |
| 64 | 63 | const messageId = MessageId.make(commandId); |
| ... | ... | @@ -147,11 +146,6 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { |
| 147 | 146 | const run = result.projection.runs.find((run) => run.userMessageId === messageId); |
| 148 | 147 | return { |
| 149 | 148 | threadId: thread.id, |
| 150 | link: formatThreadLink({ | |
| 151 | environmentId: scope.environmentId, | |
| 152 | threadId: thread.id, | |
| 153 | title: thread.title, | |
| 154 | }), | |
| 155 | 149 | projectId: thread.projectId, |
| 156 | 150 | modelSelection: thread.modelSelection, |
| 157 | 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 | 103 | const ThreadLaunchTool = Tool.make("t3_thread_launch", { |
| 104 | 104 | ...shared, |
| 105 | 105 | description: |
| 106 | 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. 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 | 107 | parameters: Schema.Struct({ |
| 108 | 108 | projectId: Schema.optional(ProjectId), |
| 109 | 109 | scratch: Schema.optional( |
| ... | ... | @@ -132,8 +132,6 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { |
| 132 | 132 | }), |
| 133 | 133 | success: Schema.Struct({ |
| 134 | 134 | threadId: ThreadId, |
| 135 | /** Paste this whenever you mention the thread, so the user can click to open it. */ | |
| 136 | link: Schema.String, | |
| 137 | 135 | projectId: ProjectId, |
| 138 | 136 | modelSelection: ModelSelection, |
| 139 | 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 | 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 | 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 | 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. | |
| 14 | 15 | |
| 15 | 16 | ### Choose the workspace before starting a new thread |
| 16 | 17 |
apps/web/src/components/ChatMarkdown.tsx+8-7| ... | ... | @@ -3167,13 +3167,14 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3167 | 3167 | } = use(ChatMarkdownRendererContext); |
| 3168 | 3168 | const citation = href ? parseAssistantCitationHref(href) : null; |
| 3169 | 3169 | if (citation) return <AssistantCitationChip citation={citation} />; |
| 3170 | // A thread link opens the thread here, never a browser. | |
| 3171 | const threadLink = href ? parseThreadLinkHref(href) : null; | |
| 3172 | if (threadLink) { | |
| 3173 | return ( | |
| 3174 | <MarkdownThreadLink {...threadLink}> | |
| 3175 | <MarkdownLinkContext value>{children}</MarkdownLinkContext> | |
| 3176 | </MarkdownThreadLink> | |
| 3170 | // A thread link names a thread in this message's environment and opens it in the app. | |
| 3171 | const linkedThreadId = href ? parseThreadLinkHref(href) : null; | |
| 3172 | if (linkedThreadId) { | |
| 3173 | const label = hastPlainTextDeep(node) || linkedThreadId; | |
| 3174 | return environmentId ? ( | |
| 3175 | <MarkdownThreadLink environmentId={environmentId} threadId={linkedThreadId} label={label} /> | |
| 3176 | ) : ( | |
| 3177 | <span>{label}</span> | |
| 3177 | 3178 | ); |
| 3178 | 3179 | } |
| 3179 | 3180 | const contextReference = href ? parseComposerContextHref(href) : null; |
apps/web/src/components/chat/MarkdownThreadLink.tsx+19-9| ... | ... | @@ -1,31 +1,41 @@ |
| 1 | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { formatThreadLink, percentDecodedThreadLinkId } from "@t3tools/shared/threadLinks"; | |
| 3 | 4 | import { Link } from "@tanstack/react-router"; |
| 4 | 5 | import { MessageSquareTextIcon } from "lucide-react"; |
| 5 | import type { ReactNode } from "react"; | |
| 6 | 6 | |
| 7 | 7 | import { useProject, useThreadShell } from "../../state/entities"; |
| 8 | 8 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 9 | 9 | |
| 10 | 10 | /** |
| 11 | * A `t3-thread://` link in chat. It leads with the thread's project icon, like | |
| 12 | * a web link leads with its favicon, so it reads as a thread link before you | |
| 13 | * hover it. Opens the thread in the app. | |
| 11 | * A `t3-thread://` link in chat. It shows the thread's current title, so a rename | |
| 12 | * reaches every message that links to it, and leads with the thread's project icon | |
| 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 | */ |
| 15 | 16 | export function MarkdownThreadLink(props: { |
| 16 | 17 | readonly environmentId: EnvironmentId; |
| 17 | 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 | 28 | const project = useProject( |
| 22 | 29 | thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId), |
| 23 | 30 | ); |
| 31 | const title = thread?.title.trim() || props.label; | |
| 24 | 32 | return ( |
| 25 | 33 | <Link |
| 26 | 34 | to="/$environmentId/$threadId" |
| 27 | params={{ environmentId: props.environmentId, threadId: props.threadId }} | |
| 28 | title={project?.title} | |
| 35 | params={{ environmentId: props.environmentId, threadId }} | |
| 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 | 40 | <span |
| 31 | 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 | 47 | <ProjectFavicon project={project} className="size-full" /> |
| 38 | 48 | )} |
| 39 | 49 | </span> |
| 40 | {props.children} | |
| 50 | {title} | |
| 41 | 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 | 352 | Actor and ingress are separate so agent-authored user-role messages remain |
| 353 | 353 | distinguishable from human-authored messages. |
| 354 | 354 | |
| 355 | List, read, and launch results include `link`, a Markdown link of the form | |
| 356 | `[title](t3-thread://v1/<environmentId>/<threadId>)` that clients open as the | |
| 357 | thread. List and read results also report `snoozed` and `snoozedUntil`, and | |
| 355 | Agents mention another thread as `[title](t3-thread://v1/<threadId>)`. The | |
| 356 | link carries only the id, which resolves in the environment of the message that | |
| 357 | holds it. Clients show the thread's current title rather than the label, so a | |
| 358 | rename never leaves a stale link. | |
| 359 | ||
| 360 | List and read results report `snoozed` and `snoozedUntil`, and | |
| 358 | 361 | `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the |
| 359 | 362 | client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread |
| 360 | 363 | wakes 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 | 304 | |
| 305 | 305 | export const OrchestratorMcpThreadListItem = Schema.Struct({ |
| 306 | 306 | threadId: ThreadId, |
| 307 | /** Paste this whenever you mention the thread, so the user can click to open it. */ | |
| 308 | link: Schema.String, | |
| 309 | 307 | title: Schema.String, |
| 310 | 308 | createdBy: OrchestrationV2Actor, |
| 311 | 309 | creationSource: OrchestrationV2CreationSource, |
| ... | ... | @@ -353,8 +351,6 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp |
| 353 | 351 | |
| 354 | 352 | export const OrchestratorMcpThreadDetail = Schema.Struct({ |
| 355 | 353 | threadId: ThreadId, |
| 356 | /** Paste this whenever you mention the thread, so the user can click to open it. */ | |
| 357 | link: Schema.String, | |
| 358 | 354 | projectId: ProjectId, |
| 359 | 355 | title: Schema.String, |
| 360 | 356 | createdBy: OrchestrationV2Actor, |
packages/shared/src/threadLinks.test.ts+66-19| ... | ... | @@ -1,28 +1,75 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts"; | |
| 3 | import { formatThreadLink, parseThreadLinkHref, relabelThreadLinks } from "./threadLinks.ts"; | |
| 4 | 4 | |
| 5 | 5 | describe("thread links", () => { |
| 6 | it("round-trips ids that contain URL characters", () => { | |
| 7 | const link = formatThreadLink({ | |
| 8 | environmentId: "studio mac", | |
| 9 | threadId: "mcp:(1)/2", | |
| 10 | title: "Fix [the] build\nnow", | |
| 11 | }); | |
| 12 | const href = /\]\((.+)\)$/.exec(link)![1]!; | |
| 13 | expect(link.startsWith("[Fix the build now](")).toBe(true); | |
| 14 | // A raw parenthesis would end the Markdown link early. | |
| 15 | expect(href).not.toMatch(/[()]/); | |
| 16 | expect(parseThreadLinkHref(href)).toEqual({ | |
| 17 | environmentId: "studio mac", | |
| 18 | threadId: "mcp:(1)/2", | |
| 19 | }); | |
| 6 | it("takes the thread id verbatim, percent escapes included", () => { | |
| 7 | expect(parseThreadLinkHref("t3-thread://v1/mcp:1234")).toBe("mcp:1234"); | |
| 8 | expect(parseThreadLinkHref("t3-thread://v1/thread:delegated-task:mcp%3A1")).toBe( | |
| 9 | "thread:delegated-task:mcp%3A1", | |
| 10 | ); | |
| 20 | 11 | }); |
| 21 | 12 | |
| 22 | it("rejects other links and malformed ones", () => { | |
| 13 | it("rejects other links and an empty id", () => { | |
| 23 | 14 | expect(parseThreadLinkHref("https://t3.codes")).toBeNull(); |
| 24 | expect(parseThreadLinkHref("t3-thread://v1/only-one")).toBeNull(); | |
| 25 | expect(parseThreadLinkHref("t3-thread://v1/env/%E0%A4%A")).toBeNull(); | |
| 26 | expect(parseThreadLinkHref("t3-thread://v1/%20/thread")).toBeNull(); | |
| 15 | expect(parseThreadLinkHref("t3-thread://v1/")).toBeNull(); | |
| 16 | expect(parseThreadLinkHref("t3-thread://v1/ ")).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 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import * as Option from "effect/Option"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | * In-app links to threads. Agents put them in Markdown as | |
| 7 | * `[title](t3-thread://v1/<environmentId>/<threadId>)`, and clients open the | |
| 8 | * thread instead of a browser. Thread tools return a ready `link` for each | |
| 9 | * thread, so agents never build one by hand. | |
| 6 | * Agents mention another thread in Markdown as `[title](t3-thread://v1/<threadId>)`. The id is | |
| 7 | * the reference and resolves in the environment of the message that holds it. Titles change, so | |
| 8 | * clients show the thread's current title; the label only stands in for a thread they cannot see. | |
| 10 | 9 | */ |
| 11 | 10 | export const THREAD_LINK_PROTOCOL = "t3-thread"; |
| 12 | 11 | const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; |
| 13 | const LINK_LABEL_MAX_CHARS = 120; | |
| 12 | // Code comes first in the alternation so a link written inside a code span or fence is skipped. | |
| 13 | const THREAD_LINK_OUTSIDE_CODE = | |
| 14 | /(?<fence>(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?<span>(`+)[^\n]*?\4)|\[[^\]\n]*\]\((?<href>t3-thread:\/\/v1\/[^\s)]+)\)/g; | |
| 14 | 15 | |
| 15 | const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId); | |
| 16 | 16 | const decodeThreadId = Schema.decodeUnknownOption(ThreadId); |
| 17 | 17 | |
| 18 | // encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early. | |
| 19 | function encodeIdSegment(id: string): string { | |
| 20 | return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29"); | |
| 21 | } | |
| 22 | ||
| 23 | function formatThreadLinkHref(environmentId: string, threadId: string): string { | |
| 24 | return `${THREAD_LINK_HREF_PREFIX}${encodeIdSegment(environmentId)}/${encodeIdSegment(threadId)}`; | |
| 25 | } | |
| 26 | ||
| 27 | /** A Markdown link to the thread, labeled with its title. */ | |
| 28 | export function formatThreadLink(thread: { | |
| 29 | readonly environmentId: string; | |
| 30 | readonly threadId: string; | |
| 31 | readonly title: string; | |
| 32 | }): string { | |
| 33 | const label = | |
| 34 | thread.title | |
| 35 | .replace(/[[\]\\\r\n]/g, " ") | |
| 36 | .replace(/\s+/g, " ") | |
| 37 | .trim() | |
| 38 | .slice(0, LINK_LABEL_MAX_CHARS) || "Untitled thread"; | |
| 39 | return `[${label}](${formatThreadLinkHref(thread.environmentId, thread.threadId)})`; | |
| 18 | /** The id as written. Thread ids can hold percent escapes of their own, so it is not decoded. */ | |
| 19 | export function parseThreadLinkHref(href: string): ThreadId | null { | |
| 20 | if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; | |
| 21 | return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length))); | |
| 40 | 22 | } |
| 41 | 23 | |
| 42 | export function parseThreadLinkHref( | |
| 43 | href: string, | |
| 44 | ): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null { | |
| 45 | if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; | |
| 46 | const parts = href.slice(THREAD_LINK_HREF_PREFIX.length).split("/"); | |
| 47 | if (parts.length !== 2) return null; | |
| 24 | /** | |
| 25 | * Agents often percent-encode the id anyway. When the id as written names no thread, clients try | |
| 26 | * this decoded form. Null when decoding changes nothing or fails. | |
| 27 | */ | |
| 28 | export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null { | |
| 48 | 29 | try { |
| 49 | const environmentId = decodeEnvironmentId(decodeURIComponent(parts[0]!)); | |
| 50 | const threadId = decodeThreadId(decodeURIComponent(parts[1]!)); | |
| 51 | return Option.isSome(environmentId) && Option.isSome(threadId) | |
| 52 | ? { environmentId: environmentId.value, threadId: threadId.value } | |
| 53 | : null; | |
| 30 | const decoded = decodeURIComponent(threadId); | |
| 31 | return decoded === threadId ? null : Option.getOrNull(decodeThreadId(decoded)); | |
| 54 | 32 | } catch { |
| 55 | // Malformed percent encoding. | |
| 56 | 33 | return null; |
| 57 | 34 | } |
| 58 | 35 | } |
| 36 | ||
| 37 | /** A thread link whose label survives Markdown: no brackets, backslashes, or line breaks. */ | |
| 38 | export 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 | ||
| 46 | export 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 | */ | |
| 54 | export 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 | } |