| 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,6 +198,7 @@ import { |
| 198 | } from "../../state/assets"; | 198 | } from "../../state/assets"; |
| 199 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; | 199 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 200 | import { usePreparedConnection } from "../../state/session"; | 200 | import { usePreparedConnection } from "../../state/session"; |
| 201 | import { useLiveThreadLinkLabels } from "../../state/entities"; | ||
| 201 | import { useThreadSelection } from "../../state/use-thread-selection"; | 202 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 202 | import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; | 203 | import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; |
| 203 | import * as Option from "effect/Option"; | 204 | import * as Option from "effect/Option"; |
| ... | @@ -906,16 +907,15 @@ interface MarkdownLinkHandlers { | ... | @@ -906,16 +907,15 @@ interface MarkdownLinkHandlers { |
| 906 | 907 | ||
| 907 | const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { | 908 | const 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 | ); | ||
| 919 | 919 | ||
| 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 | <AssistantMarkdownContent | 1906 | <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 | <AssistantMarkdownContent | 2508 | <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 | }); |
| 101 | 101 | ||
| 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"; |
| 17 | import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks"; | ||
| 16 | import { Atom } from "effect/reactivity"; | 18 | import { Atom } from "effect/reactivity"; |
| 19 | import { useMemo } from "react"; | ||
| 17 | 20 | ||
| 18 | import { environmentProjects } from "./projects"; | 21 | import { environmentProjects } from "./projects"; |
| 19 | import { environmentServerConfigsAtom, serverEnvironment } from "./server"; | 22 | import { 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 |
| 28 | const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe( | 31 | const 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 | ); |
| 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 | /** Resolves when the project event reaches the live client store. */ | 54 | /** Resolves when the project event reaches the live client store. */ |
| 33 | export function waitForProject( | 55 | export 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 | } |
| 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 | export function useEnvironmentServerConfig( | 115 | export 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 | ); |
| 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 | 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-only | 9 | * 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, with | 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 | * their forks and subagents. Working, settled, and archived threads, and | 15 | * their forks and subagents. Working, settled, and archived threads, and |
| 13 | * threads with pending recovery, are skipped, and scheduled tasks and | 16 | * 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 @@ |
| 28 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 31 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 29 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 32 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 30 | import * as NodeOS from "node:os"; | 33 | import * as NodeOS from "node:os"; |
| 34 | import * as NodeURL from "node:url"; | ||
| 31 | import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; | 35 | import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; |
| 32 | import * as Console from "effect/Console"; | 36 | import * as Console from "effect/Console"; |
| 33 | import * as Effect from "effect/Effect"; | 37 | import * 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 | ]; |
| 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 | const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { | 330 | const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { |
| 246 | const sql = yield* SqlClient.SqlClient; | 331 | const sql = yield* SqlClient.SqlClient; |
| 247 | 332 | ||
| ... | @@ -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_at | 363 | SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at |
| 279 | FROM thread_families f | 364 | FROM thread_families f |
| 280 | JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id | 365 | JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id |
| 281 | WHERE root.deleted_at IS NULL | 366 | 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_id | 367 | 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`; |
| 288 | 369 | ||
| ... | @@ -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) c | 405 | 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}`; |
| 327 | 408 | ||
| 328 | yield* sql.withTransaction( | 409 | yield* sql.withTransaction( |
| 329 | Effect.gen(function* () { | 410 | Effect.gen(function* () { |
| 330 | yield* sql`DELETE FROM projection_projects | 411 | 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_sessions | 413 | 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_bindings | 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,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 | ); |
| 361 | 437 | ||
| ... | @@ -457,22 +533,16 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( | ... | @@ -457,22 +533,16 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( |
| 457 | ); | 533 | ); |
| 458 | 534 | ||
| 459 | yield* removeDatabaseFiles(snapshotPath); | 535 | yield* removeDatabaseFiles(snapshotPath); |
| 460 | // The snapshot is a full-size copy of the source; make sure it is removed | 536 | // 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 | ); |
| 471 | 543 | ||
| 472 | // Migrate before pruning: a source older than this checkout would | 544 | // 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 61 | import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; | 60 | import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; |
| 62 | import { formatThreadLink } from "@t3tools/shared/threadLinks"; | ||
| 63 | import * as Clock from "effect/Clock"; | 61 | import * as Clock from "effect/Clock"; |
| 64 | import * as Context from "effect/Context"; | 62 | import * as Context from "effect/Context"; |
| 65 | import * as Crypto from "effect/Crypto"; | 63 | import * as Crypto from "effect/Crypto"; |
| ... | @@ -633,23 +631,12 @@ function threadSnooze( | ... | @@ -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 | function listItemFromShell( | 634 | function 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.runs | 2359 | 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 | }); |
| 2234 | 2234 | ||
| 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", { |
| 178 | 178 | ||
| 179 | const ThreadListTool = Tool.make("t3_thread_list", { | 179 | const 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", { |
| 192 | 192 | ||
| 193 | const ThreadReadTool = Tool.make("t3_thread_read", { | 193 | const 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* toolkit | 94 | 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 @@ |
| 1 | import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; | 1 | import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; |
| 2 | import { formatThreadLink } from "@t3tools/shared/threadLinks"; | ||
| 3 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 3 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Option from "effect/Option"; | 4 | import * 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", { |
| 103 | const ThreadLaunchTool = Tool.make("t3_thread_launch", { | 103 | const 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. | ||
| 14 | 15 | ||
| 15 | ### Choose the workspace before starting a new thread | 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,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 @@ |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { formatThreadLink, percentDecodedThreadLinkId } from "@t3tools/shared/threadLinks"; | ||
| 3 | import { Link } from "@tanstack/react-router"; | 4 | import { Link } from "@tanstack/react-router"; |
| 4 | import { MessageSquareTextIcon } from "lucide-react"; | 5 | import { MessageSquareTextIcon } from "lucide-react"; |
| 5 | import type { ReactNode } from "react"; | ||
| 6 | 6 | ||
| 7 | import { useProject, useThreadShell } from "../../state/entities"; | 7 | import { useProject, useThreadShell } from "../../state/entities"; |
| 8 | import { ProjectFavicon } from "../ProjectFavicon"; | 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 | 11 | * 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 you | 12 | * 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 | */ |
| 15 | export function MarkdownThreadLink(props: { | 16 | export 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 | <Link | 33 | <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 | <span | 40 | <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"`. |
| 352 | Actor and ingress are separate so agent-authored user-role messages remain | 352 | Actor and ingress are separate so agent-authored user-role messages remain |
| 353 | distinguishable from human-authored messages. | 353 | distinguishable from human-authored messages. |
| 354 | 354 | ||
| 355 | List, read, and launch results include `link`, a Markdown link of the form | 355 | Agents mention another thread as `[title](t3-thread://v1/<threadId>)`. The |
| 356 | `[title](t3-thread://v1/<environmentId>/<threadId>)` that clients open as the | 356 | link carries only the id, which resolves in the environment of the message that |
| 357 | thread. List and read results also report `snoozed` and `snoozedUntil`, and | 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 | `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the | 361 | `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the |
| 359 | client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread | 362 | client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread |
| 360 | wakes early when it has a pending request, fails, or completes after the snooze. | 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,8 +304,6 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp |
| 304 | 304 | ||
| 305 | export const OrchestratorMcpThreadListItem = Schema.Struct({ | 305 | export 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 |
| 353 | 351 | ||
| 354 | export const OrchestratorMcpThreadDetail = Schema.Struct({ | 352 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | describe("thread links", () => { | 5 | describe("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 | }); |
| 21 | 12 | ||
| 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 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | import * as Option from "effect/Option"; | 2 | import * as Option from "effect/Option"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | ||
| 5 | /** | 5 | /** |
| 6 | * In-app links to threads. Agents put them in Markdown as | 6 | * 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 the | 7 | * 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 each | 8 | * 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 | */ |
| 11 | export const THREAD_LINK_PROTOCOL = "t3-thread"; | 10 | export const THREAD_LINK_PROTOCOL = "t3-thread"; |
| 12 | const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; | 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 | const decodeThreadId = Schema.decodeUnknownOption(ThreadId); | 16 | const decodeThreadId = Schema.decodeUnknownOption(ThreadId); |
| 17 | 17 | ||
| 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. */ |
| 19 | function encodeIdSegment(id: string): string { | 19 | export 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 | |||
| 23 | function formatThreadLinkHref(environmentId: string, threadId: string): string { | ||
| 24 | return `${THREAD_LINK_HREF_PREFIX}${encodeIdSegment(environmentId)}/${encodeIdSegment(threadId)}`; | ||
| 25 | } | ||
| 26 | |||
| 27 | /** A Markdown link to the thread, labeled with its title. */ | ||
| 28 | export function formatThreadLink(thread: { | ||
| 29 | readonly environmentId: string; | ||
| 30 | readonly threadId: string; | ||
| 31 | readonly title: string; | ||
| 32 | }): string { | ||
| 33 | const label = | ||
| 34 | thread.title | ||
| 35 | .replace(/[[\]\\\r\n]/g, " ") | ||
| 36 | .replace(/\s+/g, " ") | ||
| 37 | .trim() | ||
| 38 | .slice(0, LINK_LABEL_MAX_CHARS) || "Untitled thread"; | ||
| 39 | return `[${label}](${formatThreadLinkHref(thread.environmentId, thread.threadId)})`; | ||
| 40 | } | 22 | } |
| 41 | 23 | ||
| 42 | export 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("/"); | 28 | export 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. */ | ||
| 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 | } |