From bb7783e295506ad423f527ed28a019836e331e8c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 24 Oct 2026 04:05:45 -0700 Subject: [PATCH 01/65] feat(search): Cmd+K finds the right thread first, with indexed message search (#24343) Co-authored-by: Claude Opus 5.5 --- .../features/archive/archivedThreadList.ts | 13 +- apps/mobile/src/features/home/HomeScreen.tsx | 48 +- .../src/features/keyboard/CommandPalette.tsx | 215 ++- .../keyboard/commandPaletteItems.test.ts | 50 +- .../features/keyboard/commandPaletteItems.ts | 112 +- .../features/threads/ThreadDetailScreen.tsx | 82 +- .../src/features/threads/ThreadFeed.tsx | 46 + .../threads/ThreadNavigationSidebar.tsx | 48 +- .../features/threads/ThreadRouteScreen.tsx | 6 + .../features/threads/thread-list-v2-items.tsx | 5 +- .../features/threads/thread-search-match.tsx | 7 +- .../src/features/threads/threadListV2.test.ts | 42 + .../src/features/threads/threadListV2.ts | 102 +- .../threads/threadMessageJump.test.ts | 15 + .../src/features/threads/threadMessageJump.ts | 42 + .../threads/use-composer-command-menu.ts | 3 + .../threads/useThreadListSearchMatches.ts | 58 + apps/mobile/src/lib/threadActivity.test.ts | 27 + apps/mobile/src/lib/threadActivity.ts | 22 + apps/mobile/src/state/queries.ts | 34 +- apps/server/scripts/migrate-dev-db.ts | 17 +- .../src/mcp/toolkits/thread/handlers.ts | 12 +- apps/server/src/mcp/toolkits/thread/tools.ts | 17 +- .../src/orchestration-v2/Orchestrator.ts | 18 +- .../src/orchestration-v2/ProjectionStore.ts | 2 + .../src/orchestration-v2/ThreadForkService.ts | 2 + .../ThreadManagementService.ts | 2 +- .../src/orchestration-v2/ThreadSearch.test.ts | 655 ++++++++- .../src/orchestration-v2/ThreadSearch.ts | 524 +++++-- .../orchestration-v2/messageSearchIndex.ts | 68 + .../src/orchestration-v2/runtimeLayer.test.ts | 70 + .../persistence/DatabaseMaintenance.test.ts | 24 + .../src/persistence/DatabaseMaintenance.ts | 2 + apps/server/src/persistence/Migrations.ts | 2 + .../069_ThreadMessageSearch.test.ts | 55 + .../Migrations/069_ThreadMessageSearch.ts | 106 ++ .../reconcileV2PreviewMigration.test.ts | 2 + .../components/CommandPalette.logic.test.ts | 376 ++++- .../src/components/CommandPalette.logic.ts | 274 ++-- apps/web/src/components/CommandPalette.tsx | 46 +- .../src/components/CommandPaletteResults.tsx | 60 +- .../components/CommandPaletteThreadItems.tsx | 163 ++- apps/web/src/components/Sidebar.logic.test.ts | 28 +- apps/web/src/components/Sidebar.logic.ts | 61 +- apps/web/src/components/Sidebar.tsx | 115 +- apps/web/src/components/ThreadSearchMatch.tsx | 63 +- .../components/chat/ThreadVisitWatermark.tsx | 31 + .../chat/threadFindActionBus.test.ts | 22 + .../components/chat/threadFindActionBus.ts | 70 + .../components/chat/useComposerMenuItems.ts | 4 + apps/web/src/components/chat/useThreadFind.ts | 31 +- .../src/components/settings/settingsSearch.ts | 22 +- .../components/sidebar/SidebarThreadRow.tsx | 4 +- apps/web/src/state/queries.ts | 33 +- docs/user/thread-sidebar.md | 11 +- .../src/composerThreadItems.test.ts | 50 + .../client-runtime/src/composerThreadItems.ts | 46 +- .../client-runtime/src/operations/commands.ts | 5 + packages/client-runtime/src/state/models.ts | 3 + .../src/state/threadSearch.test.ts | 97 ++ .../client-runtime/src/state/threadSearch.ts | 161 ++- .../contracts/src/orchestrationV2.test.ts | 41 + packages/contracts/src/orchestrationV2.ts | 28 + packages/contracts/src/threadSearch.ts | 20 +- packages/shared/package.json | 4 + packages/shared/src/paletteSearch.test.ts | 740 ++++++++++ packages/shared/src/paletteSearch.ts | 1260 +++++++++++++++++ packages/shared/src/threadPullRequests.ts | 60 +- packages/shared/src/threadSearch.ts | 31 - 69 files changed, 5797 insertions(+), 718 deletions(-) create mode 100644 apps/mobile/src/features/threads/threadMessageJump.test.ts create mode 100644 apps/mobile/src/features/threads/threadMessageJump.ts create mode 100644 apps/mobile/src/features/threads/useThreadListSearchMatches.ts create mode 100644 apps/server/src/orchestration-v2/messageSearchIndex.ts create mode 100644 apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts create mode 100644 apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts create mode 100644 apps/web/src/components/chat/threadFindActionBus.test.ts create mode 100644 packages/client-runtime/src/composerThreadItems.test.ts create mode 100644 packages/shared/src/paletteSearch.test.ts create mode 100644 packages/shared/src/paletteSearch.ts diff --git a/apps/mobile/src/features/archive/archivedThreadList.ts b/apps/mobile/src/features/archive/archivedThreadList.ts index 6146bba20447..ac2a0c0cab46 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.ts @@ -5,7 +5,9 @@ import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { threadSearchSubject } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId } from "@t3tools/contracts"; +import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -36,6 +38,7 @@ export function buildArchivedThreadGroups(input: { readonly sortOrder: ArchivedThreadSortOrder; }): ReadonlyArray { const query = input.searchQuery.trim().toLocaleLowerCase(); + const foldedQuery = foldSearchQuery(input.searchQuery); const groups: ArchivedThreadGroup[] = []; for (const entry of input.snapshots) { @@ -62,11 +65,15 @@ export function buildArchivedThreadGroups(input: { matchesQuery(project.title, query) || matchesQuery(project.workspaceRoot, query) || matchesQuery(environmentLabel, query); + // Threads match as in every other thread search; the list keeps its own order. const matchingThreads = groupMatches ? projectThreads - : projectThreads.filter( - (thread) => matchesQuery(thread.title, query) || matchesQuery(thread.branch, query), - ); + : rankSearchItems(projectThreads, (thread, options) => + scoreThreadSearch(foldedQuery, threadSearchSubject(thread, null), 0, { + ...options, + picks: false, + }), + ).map(({ item }) => item); if (matchingThreads.length === 0) { continue; diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 2de56b7c391c..f20a8fee636a 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -52,6 +52,8 @@ import { import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; import { useThreadSearch } from "../../state/queries"; +import { requestThreadMessageJump } from "../threads/threadMessageJump"; +import { useThreadListSearchMatches } from "../threads/useThreadListSearchMatches"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; @@ -297,16 +299,8 @@ export function HomeScreen(props: HomeScreenProps) { ? [props.selectedEnvironmentId] : []; const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); - const threadSearchMatchByKey = (() => { - const matches = new Map(); - for (const match of threadSearch.matches) { - if (match.source === "user" || match.source === "assistant") { - matches.set(threadSearchMatchKey(match), match); - } - } - return matches; - })(); - const matchedThreadKeys = new Set(threadSearch.matches.map(threadSearchMatchKey)); + const threadSearchMatchByKey = useThreadListSearchMatches(props.threads, threadSearch); + const matchedThreadKeys = new Set(threadSearchMatchByKey.keys()); const handleSwipeableWillOpen = (methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { openSwipeableRef.current?.close(); @@ -561,7 +555,19 @@ export function HomeScreen(props: HomeScreenProps) { shelfPreferencesLoading: !shelfPreferencesLoaded, }); - useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); + // A row listed for a message match opens the thread at that message, by + // tap or by shortcut. + const selectThread = (thread: EnvironmentThreadShell) => { + const key = threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }); + const messageId = threadListV2Layout.contentOnlyThreadKeys.has(key) + ? threadSearchMatchByKey.get(key)?.messageId + : undefined; + if (messageId) { + requestThreadMessageJump(scopedThreadKey(thread.environmentId, thread.id), messageId); + } + props.onSelectThread(thread); + }; + useThreadJumpShortcuts(threadListV2Items, selectThread); useEffect(() => { if (swipeEnabled) activateVisibleRows(threadListV2Items); }, [activateVisibleRows, swipeEnabled, threadListV2Items]); @@ -662,14 +668,18 @@ export function HomeScreen(props: HomeScreenProps) { : null } environmentMachine={machineByEnvironmentId.get(thread.environmentId)} - searchMatch={threadSearchMatchByKey.get( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - )} - searchQuery={props.searchQuery} - onSelectThread={props.onSelectThread} + searchMatch={ + // Only rows listed for their messages show one and open at it. + threadListV2Layout.contentOnlyThreadKeys.has( + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + ) + ? threadSearchMatchByKey.get( + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + ) + : undefined + } + searchQuery={threadSearch.query} + onSelectThread={selectThread} onOpenThreadPullRequests={handleOpenThreadPullRequests} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 99cbe597bcc1..038152aab1f0 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -1,8 +1,21 @@ import { uuidv4 } from "../../lib/uuid"; import { useNavigation } from "@react-navigation/native"; -import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import { + claimThreadOpen, + nextShownOrder, + threadSearchSubject, + type EnvironmentThreadSearchMatch, + type ShownOrder, +} from "@t3tools/client-runtime/state/thread-search"; import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; -import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; +import { + foldSearchQuery, + PALETTE_MESSAGE_LIMIT, + rankMessageMatches, + splitSearchText, + pickedQuery, + threadRecentUse, +} from "@t3tools/shared/paletteSearch"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FlatList, @@ -30,6 +43,9 @@ import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; +import { requestThreadMessageJump } from "../threads/threadMessageJump"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import { useWorkspaceEnvironments } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; @@ -39,6 +55,7 @@ import { filterCommandPaletteItems, nextPaletteIndex, type CommandPaletteItem, + type CommandPaletteResult, } from "./commandPaletteItems"; import { parseActiveThreadPath, type HardwareKeyboardCommand } from "./hardwareKeyboardCommands"; import { threadJumpIndex } from "./threadKeyboardShortcuts"; @@ -52,6 +69,8 @@ const PALETTE_COMMANDS: ReadonlyArray = [ ...THREAD_JUMP_KEYBINDING_COMMANDS, ]; const ROW_HEIGHT = 50; +// The "In messages" label above the first message match; server results always come last. +const SECTION_LABEL_HEIGHT = 26; const ACTION_ICONS: Record = { newTask: "square.and.pencil", @@ -74,7 +93,7 @@ function itemIcon(item: CommandPaletteItem): AppSymbolName { } function PaletteRow(props: { - readonly item: CommandPaletteItem; + readonly item: CommandPaletteResult; readonly index: number; readonly selected: boolean; readonly searchMatch?: EnvironmentThreadSearchMatch; @@ -121,9 +140,34 @@ function PaletteRow(props: { )} - - {props.item.title} - + + + {props.item.titleRanges?.length + ? splitSearchText(props.item.title, props.item.titleRanges).map((part) => ( + + {part.text} + + )) + : props.item.title} + + {/* The second line shows the snippet or hint, so keep where the thread + lives, unless the hint already names it. */} + {(props.searchMatch || + (props.item.matchHint && + props.item.matchHint.label !== "Project" && + props.item.matchHint.label !== "Environment")) && + props.item.detail ? ( + + {props.item.detail} + + ) : null} + {props.searchMatch ? ( + ) : props.item.matchHint ? ( + + {props.item.matchHint.label}:{" "} + {splitSearchText(props.item.matchHint.text, props.item.matchHint.ranges).map((part) => ( + + {part.text} + + ))} + ) : props.item.detail ? ( {props.item.detail} @@ -162,15 +215,18 @@ export function CommandPalette(props: { const threads = useThreadShells(); const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]); const activeThread = useThreadShell(activeThreadRef); + const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const environments = useWorkspaceEnvironments(); const { savedConnectionsById } = useSavedRemoteConnections(); const [query, setQuery] = useState(""); + // Ranking is relative to when the palette opened, so rows do not reorder as time passes. + const [now] = useState(() => Date.now()); const [selection, setSelection] = useState(null); const [visible, setVisible] = useState(true); const pendingAction = useRef<(() => void) | null>(null); const closing = useRef(false); const inputRef = useRef(null); - const listRef = useRef>(null); + const listRef = useRef>(null); const { width, height } = useWindowDimensions(); // The palette floats above every column, so it keeps itself inside the window's side insets // and to one side of a book fold. @@ -183,11 +239,6 @@ export function CommandPalette(props: { [environments], ); const search = useThreadSearch(searchEnvironmentIds, query.startsWith(">") ? "" : query); - const matchedThreadKeys = useMemo( - () => - new Set(search.matches.map((match) => scopedThreadKey(match.environmentId, match.threadId))), - [search.matches], - ); const contentMatchByKey = useMemo( () => new Map( @@ -342,11 +393,6 @@ export function CommandPalette(props: { (thread) => thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent", ) - .sort((left, right) => - (right.latestUserMessageAt ?? right.updatedAt).localeCompare( - left.latestUserMessageAt ?? left.updatedAt, - ), - ) .map((thread) => { const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); const environment = @@ -356,19 +402,30 @@ export function CommandPalette(props: { kind: "thread", title: thread.title || "Untitled thread", detail: [project?.title, environment].filter(Boolean).join(" · "), - searchTerms: [ - project?.title ?? "", - environment, - thread.branch ?? "", - ...threadPullRequestSearchTerms(thread), - ], + searchTerms: [], + threadSearch: threadSearchSubject( + thread, + project?.title, + environments.length > 1 ? environment : null, + ), + // Last among threads: picking the thread already open does nothing. + ...(thread.environmentId === activeThread?.environmentId && thread.id === activeThread.id + ? { secondary: true } + : {}), run: () => selectThread(thread), }; }); + // Recent means used recently: written in or opened, whichever is later. + threadItems.sort( + (left, right) => + (right.threadSearch ? threadRecentUse(right.threadSearch) : 0) - + (left.threadSearch ? threadRecentUse(left.threadSearch) : 0), + ); return [...actions, ...projectItems, ...threadItems]; }, [ activeThread, activeThreadRef, + environments.length, navigation, projects, runCommand, @@ -377,10 +434,68 @@ export function CommandPalette(props: { selectThreadRoute, threads, ]); - const results = useMemo( - () => filterCommandPaletteItems(items, query, matchedThreadKeys), - [items, matchedThreadKeys, query], + const rankedMessageKeys = useMemo(() => { + const subjectByKey = new Map( + items.flatMap((item) => (item.threadSearch ? [[item.key, item.threadSearch] as const] : [])), + ); + const keyOf = (match: EnvironmentThreadSearchMatch) => + scopedThreadKey(match.environmentId, match.threadId); + return rankMessageMatches( + search.matches, + (match) => subjectByKey.get(keyOf(match)), + foldSearchQuery(query), + now, + { withoutTitleMatches: true }, + ).map(keyOf); + }, [items, now, query, search.matches]); + // Rows already shown keep their places when a slower environment answers. + const [shownOrder, setShownOrder] = useState(null); + const messageOrder = nextShownOrder( + shownOrder, + search.query, + rankedMessageKeys, + PALETTE_MESSAGE_LIMIT, ); + if (messageOrder !== shownOrder) setShownOrder(messageOrder); + const messageMatchKeys = useMemo( + () => messageOrder.keys.slice(0, messageOrder.shown), + [messageOrder.keys, messageOrder.shown], + ); + const results = useMemo(() => { + const ranked = filterCommandPaletteItems(items, query, messageMatchKeys, now); + // Recent threads leave out the open one, which would always lead them. + const activeKey = activeThread + ? scopedThreadKey(activeThread.environmentId, activeThread.id) + : null; + return query.trim() ? ranked : ranked.filter((item) => item.key !== activeKey); + }, [activeThread, items, messageMatchKeys, now, query]); + const runResult = (item: CommandPaletteResult) => { + const thread = + item.kind === "thread" && query.trim() && !query.startsWith(">") + ? threads.find( + (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === item.key, + ) + : undefined; + // A message result opens the thread at that message. + const messageId = item.fromMessages ? contentMatchByKey.get(item.key)?.messageId : undefined; + if (messageId) requestThreadMessageJump(item.key, messageId); + // A pick sends its open with the query, always, so the server records it; + // the thread screen then skips its own open. The open thread is no pick. + if (thread && !item.secondary) claimThreadOpen(thread.environmentId, thread.id, Date.now()); + if (thread && thread.lastVisitedAt !== undefined && !item.secondary) { + void visitThread({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + visitedAt: thread.updatedAt, + opened: true, + searchQuery: pickedQuery(query), + }, + }); + } + item.run(); + }; + const firstMessageIndex = results.findIndex((item) => item.fromMessages); const selectedIndex = Math.max( 0, results.findIndex((item) => item.key === selection), @@ -430,7 +545,7 @@ export function CommandPalette(props: { ); } else { const item = results[threadJumpIndex(command)]; - if (item) close(item.run); + if (item) close(() => runResult(item)); } } @@ -493,7 +608,7 @@ export function CommandPalette(props: { submitBehavior="submit" onSubmitEditing={() => { const item = results[selectedIndex]; - if (item) close(item.run); + if (item) close(() => runResult(item)); }} /> @@ -505,8 +620,12 @@ export function CommandPalette(props: { keyboardShouldPersistTaps="handled" keyExtractor={(item) => item.key} getItemLayout={(_, index) => ({ - length: ROW_HEIGHT, - offset: ROW_HEIGHT * index, + length: ROW_HEIGHT + (index === firstMessageIndex ? SECTION_LABEL_HEIGHT : 0), + offset: + ROW_HEIGHT * index + + (firstMessageIndex !== -1 && index > firstMessageIndex + ? SECTION_LABEL_HEIGHT + : 0), index, })} contentContainerClassName="pb-2" @@ -515,17 +634,33 @@ export function CommandPalette(props: { {search.isPending ? "Searching…" : "No results"} } + // Message results from slower environments may still arrive below. + ListFooterComponent={ + results.length > 0 && search.isPending && !query.startsWith(">") ? ( + + Searching messages… + + ) : undefined + } renderItem={({ item, index }) => ( - close(item.run)} - /> + <> + {index === firstMessageIndex ? ( + + In messages + + ) : null} + close(() => runResult(item))} + /> + )} /> diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts index af51ce71dbfd..b5a57c2e23ed 100644 --- a/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts @@ -2,39 +2,55 @@ import { describe, expect, it } from "vite-plus/test"; import { filterCommandPaletteItems, type CommandPaletteItem } from "./commandPaletteItems"; +const NOW = Date.UTC(2026, 9, 23); + function item( key: string, title: string, kind: CommandPaletteItem["kind"], searchTerms: string[] = [], ): CommandPaletteItem { - return { key, title, kind, searchTerms, run: () => {} }; + return { + key, + title, + kind, + searchTerms, + ...(kind === "thread" ? { threadSearch: { title, activityAt: NOW } } : {}), + run: () => {}, + }; } const items = [ item("new", "New thread in…", "action", ["project", "create"]), item("settings", "Open settings", "action", ["preferences"]), item("project", "Mobile app", "project", ["/workspaces/mobile", "new thread"]), - item("siva:one", "Keyboard shortcuts", "thread", ["Mobile app", "Siva"]), - item("mac:one", "Mobile app", "thread", ["Mac"]), + item("siva:one", "Keyboard shortcuts", "thread"), + item("mac:one", "Mobile app layout", "thread"), ]; -const emptyMatches = new Set(); +const keys = (results: ReadonlyArray<{ readonly key: string }>) => + results.map((entry) => entry.key); describe("filterCommandPaletteItems", () => { - it("matches query tokens across titles and metadata and ranks exact titles first", () => { - expect( - filterCommandPaletteItems(items, " MOBILE app ", emptyMatches).map((item) => item.key), - ).toEqual(["project", "mac:one", "siva:one"]); - expect( - filterCommandPaletteItems(items, "siva keyboard", emptyMatches).map((item) => item.key), - ).toEqual(["siva:one"]); + it("ranks like the web palette, whole words first, with every token required", () => { + // A thread active today outranks a project that matches as well, but not + // one whose whole name was typed. + expect(keys(filterCommandPaletteItems(items, "mobile", [], NOW))).toEqual([ + "mac:one", + "project", + ]); + expect(keys(filterCommandPaletteItems(items, " MOBILE app ", [], NOW))).toEqual([ + "project", + "mac:one", + ]); + expect(keys(filterCommandPaletteItems(items, "keyboard short", [], NOW))).toEqual(["siva:one"]); }); - it("includes server content matches scoped to the correct environment, except in actions-only mode", () => { - const matches = new Set(["siva:one", "project"]); - expect( - filterCommandPaletteItems(items, "message content", matches).map((item) => item.key), - ).toEqual(["siva:one"]); - expect(filterCommandPaletteItems(items, "> message content", matches)).toEqual([]); + it("lists message matches after the other results, once, except in actions-only mode", () => { + const results = filterCommandPaletteItems(items, "layout", ["siva:one", "mac:one"], NOW); + expect(results.map((entry) => [entry.key, entry.fromMessages ?? false])).toEqual([ + ["mac:one", false], + ["siva:one", true], + ]); + expect(filterCommandPaletteItems(items, "> layout", ["siva:one"], NOW)).toEqual([]); }); }); diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.ts index 035d23e89d7d..4207a85b095e 100644 --- a/apps/mobile/src/features/keyboard/commandPaletteItems.ts +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.ts @@ -1,4 +1,16 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + foldSearchQuery, + PALETTE_SECONDARY_LIMIT, + PALETTE_THREAD_LIMIT, + rankSearchGroups, + scoreSearchTerms, + scoreThreadSearch, + type SearchMatchHint, + type SearchRange, + type SearchTermsMatch, + type ThreadSearchSubject, +} from "@t3tools/shared/paletteSearch"; export interface CommandPaletteItem { readonly key: string; @@ -7,40 +19,88 @@ export interface CommandPaletteItem { readonly title: string; readonly detail?: string; readonly searchTerms: ReadonlyArray; + /** Thread rows rank by match quality and use instead of by `searchTerms`. */ + readonly threadSearch?: ThreadSearchSubject; + /** Listed last in its group, as the thread already open. */ + readonly secondary?: boolean; readonly run: () => void; } +export interface CommandPaletteResult extends CommandPaletteItem { + /** Listed because its messages match; the row shows the message snippet. */ + readonly fromMessages?: boolean; + /** Title ranges the query matched, for highlighting. */ + readonly titleRanges?: ReadonlyArray; + /** Shown in place of the detail when the match is not in the title. */ + readonly matchHint?: SearchMatchHint; +} + +/** + * Ranks like the web palette: actions, projects, and threads order by their + * best match, threads by match quality and use. Threads whose messages match + * come last, in `messageMatchKeys` order, so late server results never move + * the rows above them. + */ export function filterCommandPaletteItems( items: ReadonlyArray, query: string, - matchedThreadKeys: ReadonlySet, -) { + messageMatchKeys: ReadonlyArray, + now = Date.now(), +): CommandPaletteResult[] { const actionsOnly = query.startsWith(">"); - const normalized = (actionsOnly ? query.slice(1) : query).trim().toLocaleLowerCase(); - const tokens = normalized.split(/\s+/); - return items - .flatMap((item, index) => { - if (actionsOnly && item.kind !== "action") return []; - if (!normalized) return item.kind === "project" ? [] : [{ item, rank: 0, index }]; - const title = item.title.toLocaleLowerCase(); - const haystack = [title, ...item.searchTerms].join(" ").toLocaleLowerCase(); - if ( - !tokens.every((token) => haystack.includes(token)) && - !(item.kind === "thread" && matchedThreadKeys.has(item.key)) + const normalizedQuery = foldSearchQuery(actionsOnly ? query.slice(1) : query); + const candidates = actionsOnly ? items.filter((item) => item.kind === "action") : items; + if (!normalizedQuery) return candidates.filter((item) => item.kind !== "project"); + + // On equal match classes, groups keep this order, as on web. + const kinds = ["action", "thread", "project"] as const; + const groups = rankSearchGroups( + kinds.map((kind) => ({ group: kind, items: candidates.filter((item) => item.kind === kind) })), + ( + item, + options, + ): (SearchTermsMatch & { explain?: () => SearchMatchHint | undefined }) | null => { + const match = item.threadSearch + ? scoreThreadSearch(normalizedQuery, item.threadSearch, now, options) + : scoreSearchTerms(normalizedQuery, [item.title, ...item.searchTerms], options); + // The open thread, listed last, does not decide where its group sits. + return match && item.secondary ? { ...match, groupQuality: 0 } : match; + }, + ); + const threadsMatch = groups.some(({ group }) => group === "thread"); + const ranked = groups.flatMap(({ group, ranked: entries }) => + [...entries] + // Stable, so rank order holds otherwise. + .sort( + (left, right) => + Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false), ) - return []; - const rank = - title === normalized - ? 3 - : title.startsWith(normalized) - ? 2 - : title.includes(normalized) - ? 1 - : 0; - return [{ item, rank, index }]; - }) - .sort((left, right) => right.rank - left.rank || left.index - right.index) - .map(({ item }) => item); + .slice( + 0, + group === "thread" + ? PALETTE_THREAD_LIMIT + : threadsMatch && !actionsOnly + ? PALETTE_SECONDARY_LIMIT + : entries.length, + ) + .map(({ item, match }): CommandPaletteResult => { + // Built only for the rows shown. + const matchHint = match.explain?.(); + return { ...item, titleRanges: match.ranges, ...(matchHint ? { matchHint } : {}) }; + }), + ); + if (actionsOnly) return ranked; + + const listed = new Set(ranked.map((item) => item.key)); + const threadByKey = new Map( + items.filter((item) => item.kind === "thread").map((item) => [item.key, item]), + ); + // `messageMatchKeys` arrive cut to what the palette shows; see `nextShownOrder`. + const fromMessages = messageMatchKeys.flatMap((key) => { + const item = threadByKey.get(key); + return item && !listed.has(key) ? [{ ...item, fromMessages: true }] : []; + }); + return [...ranked, ...fromMessages]; } export function nextPaletteIndex(index: number, direction: -1 | 1, count: number) { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ad29dfe5c6ec..70d7949d035a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -75,7 +75,16 @@ import type { QueuedRunEdit } from "../../state/queued-run-edit"; import type { FollowUpBehavior } from "../../lib/followUpBehavior"; import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; import * as Haptics from "expo-haptics"; -import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { + useCallback, + useContext, + useEffect, + useEffectEvent, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { AppState, Keyboard, @@ -131,6 +140,12 @@ import { ThreadBrowserFloat } from "../browser/ThreadBrowserFloat"; import { useThreadServerBrowserTabs } from "../../state/preview"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { claimThreadOpen } from "@t3tools/client-runtime/state/thread-search"; +import { + finishThreadMessageJump, + pendingThreadMessageJump, + subscribeThreadMessageJump, +} from "./threadMessageJump"; import { parentThreadIsBelow } from "../../lib/android-workspace-navigation"; import { clearThreadComposerError, @@ -404,6 +419,36 @@ function ThreadVisitWatermark(props: { const wakeIsLatestUpdate = selectedThreadUpdatedAt === selectedThreadWokeAt; const selectedThreadLastVisitedAt = thread.lastVisitedAt; const selectedThreadCompletedAt = thread.latestRun?.completedAt; + // Opening a thread is one visit that also counts toward search ranking; a + // palette pick already sent it with the query. The watermark effect below + // skips it. + const recordedOpenRef = useRef(null); + useEffect(() => { + if (!showContent) return; + if (recordedOpenRef.current === selectedThreadKey) return; + recordedOpenRef.current = selectedThreadKey; + // Recorded here even when the server cannot take opens, so recents follow it. + const claimed = claimThreadOpen(props.environmentId, selectedThreadId, Date.now()); + if (!claimed || selectedThreadLastVisitedAt === undefined) return; + lastDispatchedVisitRef.current = `${selectedThreadKey}:${selectedThreadUpdatedAt}`; + lastVisitDispatchRef.current = { threadKey: selectedThreadKey, at: Date.now() }; + void visitThread({ + environmentId: props.environmentId, + input: { + threadId: selectedThreadId, + visitedAt: selectedThreadUpdatedAt, + opened: true, + }, + }); + }, [ + props.environmentId, + selectedThreadId, + selectedThreadKey, + selectedThreadLastVisitedAt, + selectedThreadUpdatedAt, + showContent, + visitThread, + ]); useEffect(() => { if (!showContent || selectedThreadLastVisitedAt === undefined) return; const threadUpdatedAtMs = Date.parse(selectedThreadUpdatedAt); @@ -817,6 +862,39 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai (entry) => "acknowledged" in entry && entry.acknowledged === true, ); const selectedThreadFeed = props.selectedThreadFeed; + // A palette message result opens the thread at that message: earlier + // history loads until the message is in the feed, which then scrolls to it. + const jumpMessageId = useSyncExternalStore(subscribeThreadMessageJump, () => + pendingThreadMessageJump(selectedThreadKey), + ); + const jumpMessageLoaded = + jumpMessageId !== null && + selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === jumpMessageId); + // A jump this screen never reached lets go when the thread closes. + useEffect(() => () => finishThreadMessageJump(selectedThreadKey), [selectedThreadKey]); + const historyForJump = props.historyControls; + // History down to the message in one request; another page if it is not + // there yet. + const loadEarlierForJump = useEffectEvent(() => { + if (jumpMessageId !== null) historyForJump?.onLoadThrough(jumpMessageId); + }); + useEffect(() => { + if (jumpMessageId === null || jumpMessageLoaded || contentPresentationKind !== "ready") return; + if (historyForJump?.loading) return; + if (!historyForJump?.hasMoreHistory || historyForJump.error !== null) { + finishThreadMessageJump(selectedThreadKey); + return; + } + loadEarlierForJump(); + }, [ + contentPresentationKind, + historyForJump?.error, + historyForJump?.hasMoreHistory, + historyForJump?.loading, + jumpMessageId, + jumpMessageLoaded, + selectedThreadKey, + ]); const hasCompactableConversation = selectedThreadFeed.some( (entry) => @@ -1418,6 +1496,8 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} + jumpToMessageId={jumpMessageLoaded ? jumpMessageId : null} + onJumpedToMessage={() => finishThreadMessageJump(selectedThreadKey)} submittedMessageId={submittedMessageId} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} keyboardOffset={composerKeyboardOffset} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 92c67fba2971..f2f4e9a39866 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -205,6 +205,7 @@ import { isSecretRequestActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestRun, + messageFoldRunId, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { @@ -327,6 +328,8 @@ export interface ThreadFeedHistoryControls { readonly loading: boolean; readonly error: string | null; readonly onLoadEarlier: () => void; + /** Loads earlier history down to the entry, in one request. */ + readonly onLoadThrough: (entryId: string) => void; } export interface ThreadFeedProps { @@ -349,6 +352,9 @@ export interface ThreadFeedProps { readonly listRef: RefObject; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; + /** A message to scroll to once it is in the feed; see `onJumpedToMessage`. */ + readonly jumpToMessageId?: MessageId | null; + readonly onJumpedToMessage?: () => void; readonly submittedMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; readonly keyboardOffset: number; @@ -2623,6 +2629,45 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { listRef.current?.reportContentInset({ bottom }); } }, [listMountKey, props.contentInsetEndAdjustment, listRef]); + // Opening at a message: its run unfolds, the list stops following the end + // (a render later, so the end does not pull it back), then it scrolls there. + const [loadedListKey, setLoadedListKey] = useState(null); + const { jumpToMessageId } = props; + const finishJump = useEffectEvent(() => props.onJumpedToMessage?.()); + const stopFollowingEnd = useEffectEvent(() => setEndFollow(false)); + useEffect(() => { + if (!jumpToMessageId || loadedListKey !== listMountKey) return; + const index = presentedFeed.findIndex( + (entry) => entry.type === "message" && entry.id === jumpToMessageId, + ); + if (index === -1) { + const runId = messageFoldRunId(props.feed, jumpToMessageId); + if (runId && !expandedTurnIds.has(runId)) { + setInteractionState((current) => ({ + ...current, + expandedTurnIds: new Set(current.expandedTurnIds).add(runId), + })); + } else { + finishJump(); + } + return; + } + if (endFollowEnabled) { + stopFollowingEnd(); + return; + } + void listRef.current?.scrollToIndex({ index, viewPosition: 0.2, animated: false }); + finishJump(); + }, [ + endFollowEnabled, + expandedTurnIds, + jumpToMessageId, + listMountKey, + listRef, + loadedListKey, + presentedFeed, + props.feed, + ]); const reflowLayout = () => ({ rowsTop: FEED_PADDING_TOP + listHeaderSizeRef.current, @@ -3110,6 +3155,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { initialScrollAtEnd onLoad={() => { loadedListKeyRef.current = listMountKey; + setLoadedListKey(listMountKey); }} // Row positions start below the header; the reflow anchor converts between them. onMetricsChange={(metrics) => { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 6697548d81dc..13ce478929d2 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -42,6 +42,8 @@ import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; +import { requestThreadMessageJump } from "./threadMessageJump"; +import { useThreadListSearchMatches } from "./useThreadListSearchMatches"; import { useHomeListOptions } from "../home/home-list-options"; import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; import { buildHomeProjectScopes } from "../home/homeThreadList"; @@ -185,18 +187,10 @@ function ThreadNavigationSidebarPane( ? [options.selectedEnvironmentId] : []; const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); - // These maps reach LegendList's extraData, which re-renders every row whenever it changes - // identity. The compiler leaves them unmemoized because they stay live across later hook calls. - const threadSearchMatchByKey = useMemo(() => { - const matches = new Map(); - for (const match of threadSearch.matches) { - if (match.source === "user" || match.source === "assistant") { - matches.set(threadSearchMatchKey(match), match); - } - } - return matches; - }, [threadSearch.matches]); - const matchedThreadKeys = new Set(threadSearch.matches.map(threadSearchMatchKey)); + // This map reaches LegendList's extraData, which re-renders every row whenever it changes + // identity; the hook keeps it stable. + const threadSearchMatchByKey = useThreadListSearchMatches(threads, threadSearch); + const matchedThreadKeys = new Set(threadSearchMatchByKey.keys()); const projectScopeKey = options.selectedProjectKey; const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { sidebarProjectGroupingMode: options.projectGroupingMode, @@ -505,7 +499,16 @@ function ThreadNavigationSidebarPane( // These close the open row too, so they cannot be pass-throughs. The compiler would // recache a closure over `props` whenever any prop changed. const { onSelectThread, onOpenThreadPullRequests } = props; + // A row listed for a message match opens the thread at that message, by + // tap or by shortcut. const handleSelectThread = (thread: EnvironmentThreadShell) => { + const key = threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }); + const messageId = threadListV2Layout.contentOnlyThreadKeys.has(key) + ? threadSearchMatchByKey.get(key)?.messageId + : undefined; + if (messageId) { + requestThreadMessageJump(scopedThreadKey(thread.environmentId, thread.id), messageId); + } onSelectThread(thread); openSwipeableRef.current?.close(); }; @@ -629,13 +632,20 @@ function ThreadNavigationSidebarPane( : null } environmentMachine={machineByEnvironmentId.get(thread.environmentId)} - searchMatch={threadSearchMatchByKey.get( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - )} - searchQuery={props.searchQuery} + searchMatch={ + // Only rows listed for their messages show one and open at it. + threadListV2Layout.contentOnlyThreadKeys.has( + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + ) + ? threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ) + : undefined + } + searchQuery={threadSearch.query} pane="sidebar" selected={ !activeNewTask && diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index eedfa6e1d821..256a8b13b73d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -326,6 +326,12 @@ function ThreadRouteContent( input: { threadId: selectedThread.id }, }); }, + onLoadThrough: (throughEntryId: string) => { + void loadEarlierHistory({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id, throughEntryId }, + }); + }, }; })(); const navigation = useNavigation(); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 32cf4b729940..14f9b6688c4a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -602,6 +602,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const pinnedRow = props.pinned === true; const dormant = useSwipeRowDormant(props.activationKey); + const selectThread = () => onSelectThread(thread); const { providerDrivers, providerIconUrl } = useMemo(() => { const provider = props.providers?.find( (candidate) => @@ -1257,7 +1258,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }} onPress={() => { close(); - onSelectThread(thread); + selectThread(); }} style={rowAppearance.cardStyle} > @@ -1286,7 +1287,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={rowAppearance.className} onPress={() => { close(); - onSelectThread(thread); + selectThread(); }} style={rowAppearance.style} > diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index 741efc478b3e..8f37235d7b3d 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -1,4 +1,4 @@ -import { splitThreadSearchText } from "@t3tools/shared/threadSearch"; +import { findSearchTokenRanges, splitSearchText } from "@t3tools/shared/paletteSearch"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import { AppText as Text } from "../../components/ui/AppText"; @@ -12,7 +12,10 @@ export function ThreadSearchMatchExcerpt(props: { readonly sidebar?: boolean; }) { const isUser = props.match.source === "user"; - const parts = splitThreadSearchText(props.match.snippet, props.query); + const parts = splitSearchText( + props.match.snippet, + findSearchTokenRanges(props.match.snippet, props.query), + ); return ( { }); }); +describe("search lists ranked matches", () => { + const matchKey = (id: string) => + threadSearchMatchKey({ environmentId, threadId: ThreadId.make(id) }); + + it("lists matches by rank across shelves, then threads listed for their messages", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("notes"), title: "Deploy notes" }), + makeThread({ id: ThreadId.make("mentioned"), title: "Weekly notes" }), + makeThread({ id: ThreadId.make("exact"), title: "Deploy", settledOverride: "settled" }), + ], + environmentId: null, + searchQuery: "deploy", + matchedThreadKeys: new Set([matchKey("mentioned"), matchKey("notes")]), + settledShelfExpanded: false, + now: NOW, + }); + // The exact title leads even from a collapsed Settled shelf, and keeps its look. + expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["exact", "slim"], + ["notes", "card"], + ["mentioned", "card"], + ]); + expect([...layout.contentOnlyThreadKeys]).toEqual([matchKey("mentioned")]); + }); +}); + describe("sortThreadsForListV2", () => { it("honors a saved active order and leaves new threads above it", () => { const sorted = sortThreadsForListV2([ @@ -428,6 +456,20 @@ describe("buildThreadListV2Items", () => { ]); }); + it("forgives a typo even when a hidden thread matches the query exactly", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("visible"), title: "Restore drag handles" }), + makeThread({ id: ThreadId.make("archived"), title: "Drga notes", archivedAt: NOW }), + ], + environmentId: null, + searchQuery: "drga", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["visible"]); + }); + it("scopes the flat list to every environment member of a logical project", () => { const remoteEnvironmentId = EnvironmentId.make("environment-remote"); const projectId = ProjectId.make("project-1"); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 78a65df9062e..fe0683498d30 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,4 +1,3 @@ -import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { canSnooze, effectiveSnoozed, @@ -11,7 +10,11 @@ import { import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { + threadSearchMatchKey, + threadSearchSubject, +} from "@t3tools/client-runtime/state/thread-search"; +import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; import { createInboxReturnTracker, isThreadWorking, @@ -279,6 +282,8 @@ export interface ThreadListV2Layout { * a snooze expires instead of on the next minute tick. */ readonly nextSnoozeWakeAt: string | null; + /** Threads listed only because their messages match the search. */ + readonly contentOnlyThreadKeys: ReadonlySet; } export interface ThreadListV2ThreadListItem { @@ -602,7 +607,8 @@ export function buildThreadListV2Items(input: { pendingOrder: null, }), ); - const query = input.searchQuery.trim().toLocaleLowerCase(); + const query = foldSearchQuery(input.searchQuery); + const nowMs = Date.parse(now); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) : null; @@ -615,26 +621,36 @@ export function buildThreadListV2Items(input: { const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined; - for (const thread of input.threads) { - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") continue; - if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; - if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { - continue; - } - if ( - query.length > 0 && - !thread.title.toLocaleLowerCase().includes(query) && - !threadPullRequestSearchTerms(thread).some((term) => - term.toLocaleLowerCase().includes(query), - ) && - input.matchedThreadKeys?.has( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - ) !== true - ) { - continue; + const listedThreads = input.threads.filter( + (thread) => + thread.archivedAt === null && + thread.lineage.relationshipToParent !== "subagent" && + (input.environmentId === null || thread.environmentId === input.environmentId) && + (projectKeys === null || projectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ); + // The same matches as the command palette, typo fallback included. Only + // listed threads are ranked: a hidden exact match must not turn typos off. + const rankedMatches = + query.length > 0 + ? rankSearchItems(listedThreads, (thread, options) => + scoreThreadSearch(query, threadSearchSubject(thread, null), nowMs, { + ...options, + picks: false, + }), + ).map(({ item }) => item) + : null; + const matchingThreads = rankedMatches === null ? null : new Set(rankedMatches); + // Threads listed only because their messages match; only their rows show + // the message and open at it. + const contentOnlyThreadKeys = new Set(); + for (const thread of listedThreads) { + if (matchingThreads !== null && !matchingThreads.has(thread)) { + const key = threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + if (input.matchedThreadKeys?.has(key) !== true) continue; + contentOnlyThreadKeys.add(key); } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; @@ -662,6 +678,45 @@ export function buildThreadListV2Items(input: { } } + if (rankedMatches !== null) { + // Searching lists every match by rank across shelves, as the palette and + // the web sidebar do, then threads listed for their messages in the + // server's order. Each row keeps its shelf's look. + const slim = new Set([...snoozed, ...settled]); + const snoozedThreads = new Set(snoozed); + const pinnedThreads = new Set(pinned); + const threadByKey = new Map( + listedThreads.map((thread) => [ + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + thread, + ]), + ); + const contentOnly = [...(input.matchedThreadKeys ?? [])].flatMap((key) => { + const thread = contentOnlyThreadKeys.has(key) ? threadByKey.get(key) : undefined; + return thread ? [thread] : []; + }); + const results = [...rankedMatches, ...contentOnly]; + return { + items: results.map((thread, index) => ({ + thread, + variant: slim.has(thread) ? "slim" : "card", + snoozed: snoozedThreads.has(thread), + pinned: pinnedThreads.has(thread), + wokeAt: slim.has(thread) ? null : resolveThreadListV2WokeAt(thread, { now }), + isLast: index === results.length - 1, + })), + hiddenSettledCount: 0, + workingCount: 0, + workingShelfHeaderIndex: null, + snoozedCount: 0, + snoozedShelfHeaderIndex: null, + settledCount: 0, + settledShelfHeaderIndex: null, + nextSnoozeWakeAt, + contentOnlyThreadKeys, + }; + } + const orderedActive = workingShelfEnabled ? sortInboxThreadsByReturn(active, input.inboxReturnAt) : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); @@ -774,5 +829,6 @@ export function buildThreadListV2Items(input: { settledCount: orderedSettled.length, settledShelfHeaderIndex, nextSnoozeWakeAt, + contentOnlyThreadKeys, }; } diff --git a/apps/mobile/src/features/threads/threadMessageJump.test.ts b/apps/mobile/src/features/threads/threadMessageJump.test.ts new file mode 100644 index 000000000000..b77379c684ba --- /dev/null +++ b/apps/mobile/src/features/threads/threadMessageJump.test.ts @@ -0,0 +1,15 @@ +import { MessageId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { pendingThreadMessageJump, requestThreadMessageJump } from "./threadMessageJump"; + +describe("threadMessageJump", () => { + it("lets a jump lapse when its thread never showed", () => { + using now = vi.spyOn(Date, "now").mockReturnValue(0); + requestThreadMessageJump("thread-a", MessageId.make("message-1")); + expect(pendingThreadMessageJump("thread-a")).toBe("message-1"); + expect(pendingThreadMessageJump("thread-b")).toBeNull(); + now.mockReturnValue(30_000); + expect(pendingThreadMessageJump("thread-a")).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/threadMessageJump.ts b/apps/mobile/src/features/threads/threadMessageJump.ts new file mode 100644 index 000000000000..405084016138 --- /dev/null +++ b/apps/mobile/src/features/threads/threadMessageJump.ts @@ -0,0 +1,42 @@ +import type { MessageId } from "@t3tools/contracts"; + +// A palette message result opens its thread at that message. The thread +// screen reads the request while it shows that thread, and finishes it once +// the feed is there or the message cannot be found. A request whose thread +// never showed lapses, so a later plain open does not jump, as on web. +const PENDING_JUMP_MS = 30_000; +let pending: { + readonly threadKey: string; + readonly messageId: MessageId; + readonly at: number; +} | null = null; +const listeners = new Set<() => void>(); + +function notify() { + for (const listener of listeners) listener(); +} + +export function requestThreadMessageJump(threadKey: string, messageId: MessageId): void { + pending = { threadKey, messageId, at: Date.now() }; + notify(); +} + +/** The message this thread should open at, if a palette pick asked for one. */ +export function pendingThreadMessageJump(threadKey: string): MessageId | null { + return pending?.threadKey === threadKey && Date.now() - pending.at < PENDING_JUMP_MS + ? pending.messageId + : null; +} + +export function finishThreadMessageJump(threadKey: string): void { + if (pending?.threadKey !== threadKey) return; + pending = null; + notify(); +} + +export function subscribeThreadMessageJump(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 67e560b8eaa3..425767fa42b1 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -205,6 +205,8 @@ export function useComposerCommandMenu({ readonly onUsageLimits?: () => void; }) { const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); + // Thread mentions rank by recency as of when the composer mounted. + const [now] = useState(() => Date.now()); const previousOwnerKeyRef = useRef(ownerKey); const onSelectionChange = (nextSelection: ComposerEditorSelection) => { setSelection(nextSelection); @@ -500,6 +502,7 @@ export function useComposerCommandMenu({ environmentId, excludeThreadId: currentThreadId, query: trigger.query, + now, }) : []; return [ diff --git a/apps/mobile/src/features/threads/useThreadListSearchMatches.ts b/apps/mobile/src/features/threads/useThreadListSearchMatches.ts new file mode 100644 index 000000000000..8e57028430e9 --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadListSearchMatches.ts @@ -0,0 +1,58 @@ +import { + nextShownOrder, + threadSearchMatchKey, + threadSearchSubject, + type EnvironmentThreadSearchMatch, + type ShownOrder, +} from "@t3tools/client-runtime/state/thread-search"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { foldSearchQuery, rankMessageMatches } from "@t3tools/shared/paletteSearch"; +import { useMemo, useState } from "react"; + +const NO_MATCHES: ReadonlyMap = new Map(); + +/** + * A thread list's message matches by thread key, ranked across environments + * as the palette ranks them. Rows already shown keep their places while + * slower environments answer. The map keeps its identity while nothing + * changes, since lists pass it to every row. + */ +export function useThreadListSearchMatches( + threads: ReadonlyArray, + search: { + readonly matches: ReadonlyArray; + readonly query: string; + }, +): ReadonlyMap { + const [now] = useState(() => Date.now()); + const [shownOrder, setShownOrder] = useState(null); + const rankedKeys = useMemo(() => { + if (search.matches.length === 0) return []; + const subjectByKey = new Map( + threads.map((thread) => [ + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + threadSearchSubject(thread, null), + ]), + ); + return rankMessageMatches( + search.matches, + (match) => subjectByKey.get(threadSearchMatchKey(match)), + foldSearchQuery(search.query), + now, + { picks: false }, + ).map(threadSearchMatchKey); + }, [now, search.matches, search.query, threads]); + // A list shows every match, so nothing is cut; late ones go below. + const order = nextShownOrder(shownOrder, search.query, rankedKeys, Number.POSITIVE_INFINITY); + if (order !== shownOrder) setShownOrder(order); + return useMemo(() => { + if (order.keys.length === 0) return NO_MATCHES; + const matchByKey = new Map(search.matches.map((match) => [threadSearchMatchKey(match), match])); + return new Map( + order.keys.flatMap((key) => { + const match = matchByKey.get(key); + return match ? [[key, match] as const] : []; + }), + ); + }, [order, search.matches]); +} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 1163df73f4a0..c2740dd00069 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -26,6 +26,7 @@ import { buildThreadFeed, createThreadFeedCache, deriveThreadFeedPresentation, + messageFoldRunId, type ThreadFeedCache, type ThreadFeedEntry, type ThreadFeedLatestRun, @@ -524,6 +525,32 @@ describe("buildThreadFeed", () => { expect(activities.at(-1)?.prominent).toBe(true); }); + it("names the fold that hides a progress message, with or without a run", () => { + const middle = { + ...assistantMessage("2026-06-20T00:00:02.500Z"), + id: TurnItemId.make("item-middle"), + messageId: MessageId.make("message-middle"), + text: "Needle found in the build config.", + }; + const items = [userMessage(), command(), middle, assistantMessage()]; + for (const runless of [false, true]) { + const feed = buildThreadFeed( + items.map((item, position) => + projected(runless ? { ...item, runId: null } : item, position), + ), + ); + const foldRunId = messageFoldRunId(feed, "message-middle"); + expect(foldRunId, String(runless)).toBe(runless ? "runless:message-user" : runId); + const ids = (expanded: ReadonlySet) => + deriveThreadFeedPresentation(feed, null, expanded).map((entry) => entry.id); + expect(ids(new Set())).not.toContain("message-middle"); + expect(ids(new Set([foldRunId!]))).toContain("message-middle"); + } + expect(messageFoldRunId(buildThreadFeed([projected(userMessage(), 0)]), "message-user")).toBe( + null, + ); + }); + it("folds progress messages while keeping the final assistant answer visible", () => { const opening = { ...assistantMessage("2026-06-20T00:00:01.500Z"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 1e942fc8ca76..cfa562711cf1 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1064,6 +1064,28 @@ export function failedFeedRunIds( return failed; } +/** + * The key a run fold uses for the message `entryId`: its run, or for an + * imported message with no run, the runless key of the user message before + * it. Null for user messages, which never fold. + */ +export function messageFoldRunId( + feed: ReadonlyArray, + entryId: string, +): RunId | null { + let runlessKey: RunId | null = null; + for (const entry of feed) { + if (entry.type !== "message") continue; + if (entry.message.role === "user") { + if (entry.id === entryId) return null; + runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; + } else if (entry.id === entryId) { + return entry.message.runId ?? runlessKey; + } + } + return null; +} + function deriveThreadFeedRunFolds( feed: ReadonlyArray, latestRun: ThreadFeedLatestRun | null, diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 2f64e6f164af..a59dcd25e456 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -17,8 +17,12 @@ import type { import { createThreadSearchResultsAtomFamily, makeThreadSearchKey, + visibleThreadSearch, type EnvironmentThreadSearchMatch, + type SettledThreadSearch, } from "@t3tools/client-runtime/state/thread-search"; +import { THREAD_SEARCH_QUERY_MAX_LENGTH } from "@t3tools/contracts"; +import { messageSearchTokens } from "@t3tools/shared/paletteSearch"; import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; @@ -40,7 +44,7 @@ import { const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; const COMPOSER_PATH_SEARCH_LIMIT = 20; -const THREAD_SEARCH_DEBOUNCE_MS = 200; +const THREAD_SEARCH_DEBOUNCE_MS = 120; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; @@ -164,21 +168,39 @@ export function useThreadSearch( query: string, ): { readonly matches: ReadonlyArray; + /** The query `matches` are for, which lags the typed one while a search runs. */ + readonly query: string; readonly isPending: boolean; } { - const normalizedQuery = query.trim(); + // A pasted wall of text searches by its start rather than failing the request. + const normalizedQuery = query.trim().slice(0, THREAD_SEARCH_QUERY_MAX_LENGTH).trim(); const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS); - const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2; + // The server needs a word of three or more letters; shorter queries would always come back empty. + const canSearch = + environmentIds.length > 0 && messageSearchTokens(normalizedQuery).long.length > 0; const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null; const searchKey = settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery); const result = useAtomValue( searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), ); - const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; + const isPending = canSearch && (normalizedQuery !== debouncedQuery || result.isLoading); + // What fast environments returned shows without waiting for a slow one; + // callers keep shown rows in place with `nextShownOrder`. + const ready = + settledQuery !== null && (!result.isLoading || result.matches.length > 0) + ? { query: settledQuery, matches: result.matches } + : null; + // Kept from earlier renders: React's pattern for information from previous renders. + const [previous, setPrevious] = useState(null); + if (ready !== null && previous?.matches !== ready.matches) setPrevious(ready); + const visible = canSearch + ? visibleThreadSearch(normalizedQuery, ready, previous) + : { query: "", matches: EMPTY_THREAD_SEARCH_MATCHES }; return { - matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, - isPending: canSearch && (isDebouncing || result.isLoading), + matches: visible.matches, + query: visible.query, + isPending, }; } diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 2a559927176b..57c8023d6258 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -16,6 +16,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import { Command, Flag } from "effect/cli"; import * as ProjectionStore from "../src/orchestration-v2/ProjectionStore.ts"; +import { rebuildMessageSearch } from "../src/orchestration-v2/messageSearchIndex.ts"; import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -216,6 +217,7 @@ const CLEARED_TABLES: ReadonlyArray = [ ]; const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions"; +const FTS5_SHADOW_SUFFIXES = ["_data", "_idx", "_content", "_docsize", "_config"]; // Settled threads stay: they are most of real history, and the UI shows them. const rootIsVisible = (sql: SqlClient.SqlClient) => sql` @@ -236,7 +238,17 @@ const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: SELECT m.name, c."notnull" FROM src.sqlite_master m, pragma_table_info(m.name, 'src') c WHERE m.type = 'table' AND c.name = 'thread_id'`; const threadIdNullable = new Map(threadColumns.map((row) => [row.name, row.notnull === 0])); - const tables = schema.filter((entry) => entry.type === "table"); + // FTS5 makes its own shadow tables and contentless rows cannot be copied: + // create the virtual table, skip its shadow tables and rows, and rebuild the + // index once the messages and triggers are in. + const virtualTables = schema + .filter((entry) => entry.type === "table" && /^CREATE VIRTUAL TABLE/i.test(entry.sql)) + .map((entry) => entry.name); + const isShadowTable = (name: string) => + virtualTables.some((virtual) => + FTS5_SHADOW_SUFFIXES.some((suffix) => name === `${virtual}${suffix}`), + ); + const tables = schema.filter((entry) => entry.type === "table" && !isShadowTable(entry.name)); const filtered = tables.some((table) => table.name === "orchestration_v2_projection_threads"); yield* sql.withTransaction( @@ -251,7 +263,7 @@ const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: WHERE ${rootIsVisible(sql)}`; } for (const { name } of tables) { - if (CLEARED_TABLES.includes(name)) continue; + if (CLEARED_TABLES.includes(name) || virtualTables.includes(name)) continue; const nullable = threadIdNullable.get(name); const filter = !filtered ? "" @@ -271,6 +283,7 @@ const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: for (const entry of schema) { if (entry.type !== "table") yield* sql.unsafe(entry.sql).unprepared; } + yield* rebuildMessageSearch(sql); }), ); yield* sql`DETACH DATABASE src`; diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 4ab78b4fe07b..1a841542eac9 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -104,16 +104,10 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { t3_thread_search: McpToolAccess.reads((input) => Effect.gen(function* () { const { caller } = yield* readCaller(); - const { projectId: requested, ...query } = input; - const projectId = requested ?? caller?.projectId; const threadSearch = yield* ThreadSearch.ThreadSearch; - const result = yield* threadSearch.search(query).pipe(Effect.mapError(unavailable)); - return { - matches: - projectId === undefined - ? result.matches - : result.matches.filter((match) => match.projectId === projectId), - }; + return yield* threadSearch + .searchThreads({ ...input, projectId: input.projectId ?? caller?.projectId }) + .pipe(Effect.mapError(unavailable)); }), ), t3_thread_fork: writesThread((input) => diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 21750cbb6eaa..c23f032522eb 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -2,7 +2,7 @@ import { ScheduledTaskId, ScheduledTask, OrchestrationSearchThreadsInput, - OrchestrationSearchThreadsResult, + OrchestrationThreadSearchMatch, OrchestrationV2ThreadForkSourcePoint, OrchestrationV2ContextTransfer, TrimmedNonEmptyString, @@ -243,12 +243,23 @@ const ThreadTransfersTool = Tool.make("t3_thread_transfers", { const ThreadSearchTool = Tool.make("t3_thread_search", { ...commandTool, description: - "Search active thread titles and content with the app's existing bounded search. Matches are limited to one project (projectId, else the calling thread's project) out of the global top matches, so this may return fewer than limit. A caller outside a T3 thread that omits projectId searches every project. No pagination or exhaustive-result guarantee.", + 'Search active threads the way the command palette does: title, branch, pull request, and project matches first (source "title", ranked by match quality and recent use, with one-typo tolerance), then threads whose messages contain every query word of three or more characters (source "user" or "assistant", with a snippet; up to a fifth of the limit is kept for these). Subagent threads are skipped. Limited to one project (projectId, else the calling thread\'s project); a caller outside a T3 thread that omits projectId searches every project. No pagination or exhaustive-result guarantee.', parameters: Schema.Struct({ ...OrchestrationSearchThreadsInput.fields, projectId: Schema.optional(ProjectId), }), - success: OrchestrationSearchThreadsResult, + success: Schema.Struct({ + matches: Schema.Array( + Schema.Struct({ + threadId: OrchestrationThreadSearchMatch.fields.threadId, + projectId: OrchestrationThreadSearchMatch.fields.projectId, + title: Schema.String, + source: Schema.Literals(["title", "user", "assistant"]), + snippet: Schema.optional(OrchestrationThreadSearchMatch.fields.snippet), + messageCreatedAt: Schema.optional(OrchestrationThreadSearchMatch.fields.messageCreatedAt), + }), + ), + }), dependencies: [...commandTool.dependencies, ThreadSearch.ThreadSearch], }) .annotate(Tool.Readonly, true) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index d2e9333c6999..1a650c81f747 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -64,6 +64,7 @@ import { type TurnItemId, } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { recordThreadOpen } from "@t3tools/shared/paletteSearch"; import { derivePendingBackgroundWork, pendingBackgroundTurnItems, @@ -2566,6 +2567,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const movesForward = thread.lastVisitedAt === null || DateTime.toEpochMillis(visitedAt.value) > DateTime.toEpochMillis(thread.lastVisitedAt); + const occurredAt = yield* DateTime.now; + // Opens are timed by the server clock so devices with skewed clocks agree. + const opens = command.opened + ? recordThreadOpen( + thread.opens ?? null, + DateTime.toEpochMillis(occurredAt), + command.searchQuery, + ) + : null; // Viewing a thread changes read state only. Loading its transcript (or // bumping updatedAt) makes a routine read receipt scale with its history. yield* emit( @@ -2575,8 +2585,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: "thread.visited", threadId: command.threadId, providerInstanceId: thread.providerInstanceId, - occurredAt: yield* DateTime.now, - payload: movesForward ? { ...thread, lastVisitedAt: visitedAt.value } : thread, + occurredAt, + payload: { + ...thread, + ...(movesForward ? { lastVisitedAt: visitedAt.value } : {}), + ...(opens ? { opens } : {}), + }, }); }); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index f62d5e447aae..8deb5bf7e93f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1678,6 +1678,7 @@ export function threadShellFromProjection( autoSettleDisabledAt: projection.thread.autoSettleDisabledAt ?? null, pinOrderKey: projection.thread.pinOrderKey ?? null, lastVisitedAt: projection.thread.lastVisitedAt, + opens: projection.thread.opens ?? null, titleRegeneration: projection.thread.titleRegeneration ?? null, limitRecovery: projection.thread.limitRecovery ?? null, deletedAt: projection.thread.deletedAt, @@ -1921,6 +1922,7 @@ function shellFromState(input: { autoSettleDisabledAt: input.state.thread.autoSettleDisabledAt ?? null, pinOrderKey: input.state.thread.pinOrderKey ?? null, lastVisitedAt: input.state.thread.lastVisitedAt, + opens: input.state.thread.opens ?? null, titleRegeneration: input.state.thread.titleRegeneration ?? null, limitRecovery: input.state.thread.limitRecovery ?? null, deletedAt: input.state.thread.deletedAt, diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index c31a5e4611a2..5c9ccb981c91 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -111,6 +111,8 @@ export const layer: Layer.Layer = Layer.succeed( snoozedUntil: null, snoozedAt: null, lastVisitedAt: null, + // A fork is a new thread; it does not inherit how often the source was opened. + opens: null, deletedAt: null, }; const transfer: OrchestrationV2ContextTransfer = { diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index fba2df1de5c2..78b3aa07d247 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -87,7 +87,7 @@ function existingThreadIdsForCommand( case "thread.create": return []; // Read-state commands only rewrite the thread payload's visited/unread - // watermark; they never touch messages, so they do not need the imported + // watermark and open stats; they never touch messages, so they do not need the imported // v1 transcript hydrated first. Visits fire on every thread-activity bump // while a thread is open, so keeping them off the import path matters. case "thread.visit": diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index f12355a49e55..13042a32846b 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -11,6 +11,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -59,7 +60,12 @@ const createProject = (projectId: ProjectId) => const thread = ( threadId: ThreadId, projectId: ProjectId, - overrides: { readonly archivedAt?: DateTime.Utc; readonly deletedAt?: DateTime.Utc } = {}, + overrides: { + readonly archivedAt?: DateTime.Utc; + readonly deletedAt?: DateTime.Utc; + readonly subagentOf?: ThreadId; + readonly title?: string; + } = {}, ): OrchestrationV2DomainEvent => ({ id: EventId.make(`created:${threadId}`), type: "thread.created", @@ -71,7 +77,7 @@ const thread = ( creationSource: "web", id: threadId, projectId, - title: threadId, + title: overrides.title ?? threadId, providerInstanceId, modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, runtimeMode: "full-access", @@ -79,7 +85,13 @@ const thread = ( branch: null, worktreePath: null, activeProviderThreadId: null, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + lineage: overrides.subagentOf + ? { + parentThreadId: overrides.subagentOf, + relationshipToParent: "subagent", + rootThreadId: overrides.subagentOf, + } + : { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, forkedFrom: null, createdAt: at(0), updatedAt: at(0), @@ -96,7 +108,11 @@ const message = ( id: string, role: "user" | "assistant" | "system", text: string, - options: { readonly minute?: number; readonly streaming?: boolean } = {}, + options: { + readonly minute?: number; + readonly streaming?: boolean; + readonly createdBy?: "user" | "agent"; + } = {}, ): OrchestrationV2DomainEvent => ({ id: EventId.make(`message:${id}`), type: "message.updated", @@ -104,7 +120,7 @@ const message = ( providerInstanceId, occurredAt: at(options.minute ?? 1), payload: { - createdBy: role === "user" ? "user" : "agent", + createdBy: options.createdBy ?? (role === "user" ? "user" : "agent"), creationSource: role === "user" ? "web" : "provider", id: MessageId.make(id), threadId, @@ -120,6 +136,21 @@ const message = ( }); it.layer(layerTest)("ThreadSearch", (it) => { + it.effect("reads the newest hits without sorting all of them", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + for (const match of ['"needle"', null]) { + const plan = yield* sql<{ + readonly detail: string; + }>`EXPLAIN QUERY PLAN ${ThreadSearch.messageHitsQuery(sql, { match, substrings: "[]", projectId: null, excludedThreadIds: "[]" })}`; + assert.isFalse( + plan.some((row) => row.detail.includes("TEMP B-TREE")), + plan.map((row) => row.detail).join("\n"), + ); + } + }), + ); + it.effect("returns one finished user or assistant match per active thread", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -168,18 +199,626 @@ it.layer(layerTest)("ThreadSearch", (it) => { const result = yield* search.search({ query: "NEEDLE", limit: 20 }); assert.deepEqual( - result.matches.map((match) => [match.threadId, match.source, match.snippet]), + result.matches + .map((match) => [match.threadId, match.source, match.snippet, match.messageId]) + .toSorted(([left], [right]) => String(left).localeCompare(String(right))), [ - [both, "user", "newer needle question"], - [assistantOnly, "assistant", "needle in an answer"], + [assistantOnly, "assistant", "needle in an answer", "assistant-only"], + [both, "user", "newer needle question", "both-user-new"], ], ); assert.lengthOf((yield* search.search({ query: "needle", limit: 1 })).matches, 1); assert.deepEqual((yield* search.search({ query: "ne%le" })).matches, []); }), ); + + it.effect("indexes a message when it finishes streaming and follows later edits", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:search-sync"); + const threadId = ThreadId.make("thread:sync"); + yield* createProject(project); + yield* projections.apply(thread(threadId, project)); + const threadIds = (query: string) => + search.search({ query }).pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + + yield* projections.apply( + message(threadId, "sync", "assistant", "streaming draft", { streaming: true }), + ); + assert.deepEqual(yield* threadIds("draft"), []); + + yield* projections.apply(message(threadId, "sync", "assistant", "finished answer")); + assert.deepEqual(yield* threadIds("finished"), [threadId]); + + yield* projections.apply(message(threadId, "sync", "assistant", "edited reply")); + assert.deepEqual(yield* threadIds("finished"), []); + assert.deepEqual(yield* threadIds("edited"), [threadId]); + }), + ); + + it.effect("matches inside identifiers and skips subagent threads and short tokens", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:search-tokens"); + const parent = ThreadId.make("thread:tokens-parent"); + const subagent = ThreadId.make("thread:tokens-subagent"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(parent, project), + message(parent, "parent", "user", "Rename ThreadSearchService before shipping"), + thread(subagent, project, { subagentOf: parent }), + message(subagent, "subagent", "assistant", "ThreadSearchService renamed"), + ], + projections.apply, + { discard: true }, + ); + + const result = yield* search.search({ query: "search servi" }); + assert.deepEqual( + result.matches.map((match) => [match.threadId, match.snippet]), + [[parent, "Rename ThreadSearchService before shipping"]], + ); + // "ab" alone cannot use the index; with a longer token it narrows the hits. + assert.deepEqual((yield* search.search({ query: "ab" })).matches, []); + assert.lengthOf((yield* search.search({ query: "renam be" })).matches, 1); + assert.lengthOf((yield* search.search({ query: "renam ab" })).matches, 0); + }), + ); + + it.effect("indexes neither subagent messages nor rewrites that keep the text", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const project = ProjectId.make("project:search-index-writes"); + const parent = ThreadId.make("thread:index-parent"); + const subagent = ThreadId.make("thread:index-subagent"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(parent, project), + message(parent, "index-parent", "assistant", "parent answer"), + thread(subagent, project, { subagentOf: parent }), + message(subagent, "index-subagent", "assistant", "subagent answer"), + ], + projections.apply, + { discard: true }, + ); + const indexedThreads = sql<{ readonly threadId: string }>` + SELECT messages.thread_id AS "threadId" + FROM orchestration_v2_message_search AS search + INNER JOIN orchestration_v2_projection_messages AS messages + ON messages.rowid = search.rowid + WHERE messages.thread_id IN (${parent}, ${subagent}) + `.pipe(Effect.map((rows) => rows.map((row) => row.threadId))); + assert.deepEqual(yield* indexedThreads, [parent]); + + // total_changes counts trigger writes, so 1 means the index was untouched. + const changes = sql<{ readonly n: number }>`SELECT total_changes() AS n`.pipe( + Effect.map(([row]) => row?.n ?? 0), + ); + const before = yield* changes; + yield* sql` + UPDATE orchestration_v2_projection_messages + SET payload_json = json_set(payload_json, '$.updatedAt', '2026-09-27T01:00:00.000Z') + WHERE message_id = 'index-parent' + `; + assert.strictEqual((yield* changes) - before, 1); + assert.deepEqual(yield* indexedThreads, [parent]); + }), + ); }); +it.layer(layerTest)("ThreadSearch filters before it ranks", (it) => { + it.effect("orders message matches by when they were written, not by row order", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:written"); + yield* createProject(project); + const recent = ThreadId.make("thread:written-recent"); + const imported = ThreadId.make("thread:written-imported"); + // An imported history lands after newer messages in row order. + yield* Effect.forEach( + [ + thread(recent, project), + message(recent, "written-recent", "user", "kestrel deploy notes", { minute: 30 }), + thread(imported, project), + message(imported, "written-imported", "user", "kestrel deploy notes", { minute: 1 }), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.search({ query: "kestrel" }); + assert.deepEqual( + matches.map((match) => match.threadId), + [recent, imported], + ); + }), + ); + + it.effect("ranks a thread about the word above a passing mention", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:about"); + yield* createProject(project); + const about = ThreadId.make("thread:about-osprey"); + const mention = ThreadId.make("thread:mention-osprey"); + yield* Effect.forEach( + [ + thread(about, project), + ...Array.from({ length: 12 }, (_, index) => + message(about, `about-osprey-${index}`, "assistant", `osprey step ${index}`, { + minute: 2, + }), + ), + thread(mention, project), + message(mention, "mention-osprey", "user", "also check osprey", { minute: 5 }), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.search({ query: "osprey" }); + assert.deepEqual( + matches.map((match) => [match.threadId, match.hits]), + [ + [about, 12], + [mention, 1], + ], + ); + }), + ); + + it.effect("counts a thread's hits at its best match only", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:best-hits"); + yield* createProject(project); + const clarks = ThreadId.make("thread:clarks"); + const larks = ThreadId.make("thread:lark-many"); + yield* Effect.forEach( + [ + thread(clarks, project), + message(clarks, "clarks-lark", "user", "Lark once", { minute: 3 }), + ...Array.from({ length: 31 }, (_, index) => + message(clarks, `clarks-${index}`, "assistant", "Clark the plan", { minute: 3 }), + ), + thread(larks, project), + ...Array.from({ length: 8 }, (_, index) => + message(larks, `lark-many-${index}`, "assistant", "Lark again", { minute: 3 }), + ), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.search({ query: "lark" }); + assert.deepEqual( + matches.map((match) => [match.threadId, match.hits]), + [ + [larks, 8], + [clarks, 1], + ], + ); + }), + ); + + it.effect("finds a visible match behind hundreds of hidden or narrower hits", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:crowded"); + const parent = ThreadId.make("thread:crowded-parent"); + const subagent = ThreadId.make("thread:crowded-subagent"); + const older = ThreadId.make("thread:crowded-older"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(parent, project), + thread(subagent, project, { subagentOf: parent }), + thread(older, project), + ...Array.from({ length: 450 }, (_, index) => + message(subagent, `subagent-${index}`, "assistant", "needle needle"), + ), + ...Array.from({ length: 450 }, (_, index) => + message(older, `older-${index}`, "assistant", "pnpm 11 pnpm"), + ), + message(parent, "parent-needle", "user", "a needle in a long haystack of other words"), + // Agent-written notices in user messages are not indexed, so they never read as "You". + message(older, "older-notice", "user", "needle update from a watched pull request", { + createdBy: "agent", + }), + message(parent, "parent-pnpm", "user", "moved the repo to pnpm 12 today"), + ], + projections.apply, + { discard: true }, + ); + + const threadIds = (query: string) => + search.search({ query }).pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + assert.deepEqual(yield* threadIds("needle"), [parent]); + assert.deepEqual(yield* threadIds("pnpm 12"), [parent]); + }), + ); + + it.effect("keeps a thread picked for the query when the limit cuts", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:plover"); + const busy = ThreadId.make("thread:plover-busy"); + const picked = ThreadId.make("thread:plover-picked"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(busy, project), + message(busy, "plover-1", "user", "plover notes", { minute: 3 }), + message(busy, "plover-2", "user", "more plover notes", { minute: 4 }), + thread(picked, project), + message(picked, "plover-old", "user", "one plover mention", { minute: 1 }), + ], + projections.apply, + { discard: true }, + ); + const threadIds = () => + search + .search({ query: "plover", limit: 1 }) + .pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + assert.deepEqual(yield* threadIds(), [busy]); + // A pick at time 0 would read as no pick. + yield* TestClock.adjust("1 day"); + const at = DateTime.toEpochMillis(yield* DateTime.now); + yield* sql` + UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.opens', json(${JSON.stringify({ + openedAt: at, + score: 1, + picks: [{ query: "plover", at }], + })})) + WHERE thread_id = ${picked} + `; + assert.deepEqual(yield* threadIds(), [picked]); + }), + ); + + it.effect("finds a curly apostrophe for a straight one and back", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:apostrophes"); + const curly = ThreadId.make("thread:apostrophe-curly"); + const straight = ThreadId.make("thread:apostrophe-straight"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(curly, project), + message(curly, "apostrophe-curly", "user", "the gannet\u2019s nest"), + thread(straight, project), + message(straight, "apostrophe-straight", "user", "a kittiwake's nest"), + ], + projections.apply, + { discard: true }, + ); + const threadIds = (query: string) => + search.search({ query }).pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + assert.deepEqual(yield* threadIds("gannet's"), [curly]); + assert.deepEqual(yield* threadIds("kittiwake\u2019s"), [straight]); + }), + ); + + it.effect("leaves out answers to an agent's question, which show in its card", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:answers"); + const asked = ThreadId.make("thread:answers"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(asked, project), + message(asked, "async-answer:request-1", "user", "use the albatross drive"), + ], + projections.apply, + { discard: true }, + ); + assert.deepEqual((yield* search.search({ query: "albatross" })).matches, []); + }), + ); + + it.effect("matches messages as they read, with links as their text", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:heron"); + const labeled = ThreadId.make("thread:heron-label"); + const addressOnly = ThreadId.make("thread:heron-address"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(labeled, project), + message(labeled, "heron-label", "assistant", "See [the heron plan](https://x.dev/a) now"), + thread(addressOnly, project), + message(addressOnly, "heron-address", "user", "Read [this](https://heron.dev/b)"), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.search({ query: "heron" }); + assert.deepEqual( + matches.map((match) => [match.threadId, match.snippet]), + [[labeled, "See the heron plan now"]], + ); + }), + ); + + it.effect("reads at most the newest 10,000 index hits, however many the filters drop", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:kestrel"); + const old = ThreadId.make("thread:kestrel-old"); + const busy = ThreadId.make("thread:kestrel-busy"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(old, project), + message(old, "kestrel-old", "user", "kestrel ci is red"), + thread(busy, project), + ], + projections.apply, + { discard: true }, + ); + const insertBusy = (count: number) => sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${count}) + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, role, streaming, created_at, updated_at, payload_json) + SELECT 'kestrel-busy-' || i, ${busy}, 'assistant', 0, ${DateTime.formatIso(at(5))}, + ${DateTime.formatIso(at(5))}, json_object('text', 'kestrel packages') + FROM n + `; + const threadIds = (query: string) => + search.search({ query }).pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + yield* insertBusy(9_999); + assert.deepEqual(yield* threadIds("kestrel ci"), [old]); + yield* sql`DELETE FROM orchestration_v2_projection_messages WHERE thread_id = ${busy}`; + yield* insertBusy(10_000); + assert.deepEqual(yield* threadIds("kestrel ci"), []); + }), + ); +}); + +it.layer(layerTest)("ThreadSearch across scripts and without the index", (it) => { + it.effect( + "matches Korean, Japanese, and two-character Chinese words, and whole short words", + () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:scripts"); + const threadId = ThreadId.make("thread:scripts"); + const drafts = ThreadId.make("thread:drafts"); + const addresses = ThreadId.make("thread:addresses"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(threadId, project), + message(threadId, "korean", "user", "검색 기능을 고쳐 주세요"), + message(threadId, "japanese", "user", "がんばって検索します"), + message(threadId, "chinese", "user", "明天部署到生产环境"), + message(threadId, "fts", "assistant", "the FTS index is ready"), + thread(drafts, project), + message(drafts, "drafts", "assistant", "saved two drafts and shifts"), + message(threadId, "camel", "assistant", "Configure enableFTS today"), + message(threadId, "accented", "user", "Review Résumé indexing"), + thread(addresses, project), + message(addresses, "addresses", "user", "This addresses Résumé indexing"), + ], + projections.apply, + { discard: true }, + ); + const threadIds = (query: string) => + search + .search({ query }) + .pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + + // Short words must start a word, which camelCase and accented words do. + for (const query of ["검색 기능", "がんばって", "部署", "fts", "enable"]) { + assert.deepEqual(yield* threadIds(query), [threadId], query); + } + // "res" inside "addresses" does not hide the accented word after it. + assert.sameMembers(yield* threadIds("res"), [threadId, addresses]); + // A word beats the same letters inside a longer word, even in an older message. + const trust = ThreadId.make("thread:trust"); + const rust = ThreadId.make("thread:rust"); + yield* Effect.forEach( + [ + thread(rust, project), + message(rust, "rust", "assistant", "Rust rewrite notes"), + thread(trust, project), + message(trust, "trust", "user", "We trust the build"), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.search({ query: "rust" }); + assert.deepEqual( + matches.map((match) => [match.threadId, (match.quality ?? 1) <= 0.3]), + [ + [rust, false], + [trust, true], + ], + ); + // Without the index (an SQLite build without FTS5 trigrams) search scans instead. + yield* sql`DROP TABLE orchestration_v2_message_search`; + assert.deepEqual(yield* threadIds("検索"), [threadId]); + assert.deepEqual(yield* threadIds("fts"), [threadId]); + }), + ); +}); + +it.layer(layerTest)("ThreadSearch.searchThreads", (it) => { + it.effect("keeps room for a message-only thread behind many titled ones", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:petrel"); + yield* createProject(project); + const messageOnly = ThreadId.make("thread:petrel-message"); + yield* Effect.forEach( + [ + thread(messageOnly, project), + // The oldest message, so a cut taken before titles drop would lose it. + message(messageOnly, "petrel-old", "user", "a petrel note", { minute: 1 }), + ...Array.from({ length: 50 }, (_, index) => { + const id = ThreadId.make(`thread:petrel-${index}`); + return [ + thread(id, project, { title: `Petrel ${index}` }), + message(id, `petrel-${index}`, "user", "petrel again", { minute: 2 }), + ]; + }).flat(), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.searchThreads({ query: "petrel", limit: 5 }); + assert.include( + matches.map((match) => match.threadId), + messageOnly, + ); + }), + ); + + it.effect("leaves titled threads out before the hit limit", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:hit-limit"); + const messageOnly = ThreadId.make("thread:grebe-message"); + const titled = ThreadId.make("thread:grebe-titled"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(messageOnly, project, { title: "Unrelated" }), + message(messageOnly, "grebe-old", "user", "a grebe note", { minute: 1 }), + thread(titled, project, { title: "Grebe" }), + ], + projections.apply, + { discard: true }, + ); + // Newer hits than one read holds, all in the titled thread. + yield* sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 2000) + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, role, streaming, created_at, updated_at, payload_json) + SELECT 'grebe-titled-' || i, ${titled}, 'assistant', 0, ${DateTime.formatIso(at(5))}, + ${DateTime.formatIso(at(5))}, json_object('text', 'grebe again') + FROM n + `; + const { matches } = yield* search.searchThreads({ query: "grebe", limit: 5 }); + assert.include( + matches.map((match) => match.threadId), + messageOnly, + ); + }), + ); + + it.effect("ranks title matches like the palette, then message matches, within one project", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:titles"); + const otherProject = ProjectId.make("project:titles-other"); + yield* createProject(project); + yield* createProject(otherProject); + const wordStart = ThreadId.make("thread:word-start"); + const midWord = ThreadId.make("thread:mid-word"); + const messageOnly = ThreadId.make("thread:message-only"); + const elsewhere = ThreadId.make("thread:elsewhere"); + yield* Effect.forEach( + [ + thread(midWord, project, { title: "Fix oauth redirect" }), + thread(wordStart, project, { title: "Clean up auth tokens" }), + thread(messageOnly, project, { title: "Unrelated" }), + message(messageOnly, "message-only", "user", "the auth flow broke again"), + thread(elsewhere, otherProject, { title: "Auth everywhere" }), + ], + projections.apply, + { discard: true }, + ); + + // Stronger message hits in another project must not use up this project's limit. + yield* Effect.forEach( + Array.from({ length: 55 }, (_, index) => { + const busy = ThreadId.make(`thread:elsewhere-${index}`); + return [ + thread(busy, otherProject), + message(busy, `elsewhere-${index}`, "user", "auth auth auth"), + ]; + }).flat(), + projections.apply, + { discard: true }, + ); + const result = yield* search.searchThreads({ query: "auth", projectId: project, limit: 3 }); + assert.deepEqual( + result.matches.map((match) => [match.threadId, match.source]), + [ + [wordStart, "title"], + [midWord, "title"], + [messageOnly, "user"], + ], + ); + // With one slot, an exact title keeps it. + assert.deepEqual( + (yield* search.searchThreads({ query: "auth", projectId: project, limit: 1 })).matches.map( + (match) => match.threadId, + ), + [wordStart], + ); + // Exact matching finds nothing, so one typo is forgiven. + assert.deepEqual( + (yield* search.searchThreads({ query: "tokesn", projectId: project })).matches.map( + (match) => match.title, + ), + ["Clean up auth tokens"], + ); + }), + ); +}); + +it.effect("starts snippets a few words before the match so one line shows it", () => + Effect.sync(() => { + const snippet = (text: string, word: string) => + ThreadSearch.buildSearchSnippet(text, text.indexOf(word)); + const long = `${"lead ".repeat(60)}the NEEDLE sits here ${"tail ".repeat(60)}`; + const cut = snippet(long, "NEEDLE"); + assert.isAtMost(cut.length, 240); + assert.match(cut, /^…(lead ){2,4}the NEEDLE sits here/); + assert.isTrue(cut.endsWith("…")); + // Whitespace collapses only in the shown part, so the offset still lands. + assert.match(snippet(`${"word\n\n ".repeat(60)}un café`, "café"), /un café$/); + // A cut never splits an emoji's surrogate pair. + assert.notMatch( + snippet(`x${"🚀".repeat(200)}needle`, "needle"), + /[\ud800-\udbff](?![\udc00-\udfff])|(? Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index 0862c5160dad..4831cea1b43a 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -1,18 +1,46 @@ import { IsoDateTime, + MessageId, OrchestrationThreadSearchSource, + OrchestrationV2AppThreadJson, + OrchestrationV2ThreadOpens, type OrchestrationSearchThreadsInput, type OrchestrationSearchThreadsResult, ProjectId, ThreadId, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlSchema from "effect/sql/SqlSchema"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { + messageSearchTokens, + foldSearchQuery, + rankMessageMatches, + rankSearchItems, + messageMatcher, + scoreThreadSearch, + type ThreadSearchSubject, +} from "@t3tools/shared/paletteSearch"; +import { threadSearchPullRequests } from "@t3tools/shared/threadPullRequests"; +import { indexedMessageCondition } from "./messageSearchIndex.ts"; + +// When the user last wrote in the thread aliased `threads`, as the clients +// rank recency; agent notices in the user role do not count. +const latestUserMessageAt = (sql: SqlClient.SqlClient) => sql`( + SELECT messages.updated_at + FROM orchestration_v2_projection_messages AS messages + WHERE messages.thread_id = threads.thread_id + AND messages.role = 'user' + AND COALESCE(json_extract(messages.payload_json, '$.createdBy'), 'user') = 'user' + ORDER BY messages.updated_at DESC, messages.message_id DESC + LIMIT 1 +)`; export class ThreadSearchError extends Schema.TaggedError()( "ThreadSearchError", @@ -26,45 +54,211 @@ export class ThreadSearchError extends Schema.TaggedError()( } } -const SearchRequest = Schema.Struct({ pattern: Schema.String, limit: Schema.Int }); +// Token lists bind as JSON arrays, so any number of them is one parameter. +const TokenList = Schema.fromJsonString(Schema.Array(Schema.String)); +const SearchRequest = Schema.Struct({ + /** The FTS5 query, or null to scan messages instead. */ + match: Schema.NullOr(Schema.String), + /** Tokens every hit must contain, checked on the text. */ + substrings: TokenList, + projectId: Schema.NullOr(ProjectId), + /** Threads the caller already lists, left out before the hit limit. */ + excludedThreadIds: TokenList, +}); const SearchRow = Schema.Struct({ + messageRowid: Schema.Number, + messageId: MessageId, threadId: ThreadId, projectId: ProjectId, source: OrchestrationThreadSearchSource, - matchText: Schema.String, + text: Schema.String, messageCreatedAt: Schema.NullOr(IsoDateTime), }); -function escapeLikePattern(value: string): string { - return value.replaceAll("!", "!!").replaceAll("%", "!%").replaceAll("_", "!_"); +const ThreadOpensRow = Schema.Struct({ + threadId: ThreadId, + opens: Schema.NullOr(Schema.String), + latestUserMessageAt: Schema.NullOr(IsoDateTime), +}); +const decodeThreadOpens = Schema.decodeUnknownOption( + Schema.fromJsonString(OrchestrationV2ThreadOpens), +); + +const SNIPPET_LENGTH = 240; +// The newest hits read per query. A common word stops here instead of reading +// every message, so time stays flat as history grows. +const MESSAGE_HIT_LIMIT = 2000; +// How many of the newest index hits the filters may pass over to fill that +// limit: a rare short word or hidden threads would otherwise read every hit. +const MESSAGE_CANDIDATE_LIMIT = 10_000; +// Without the index, how many of the newest messages a search scans. +const MESSAGE_SCAN_LIMIT = 20_000; + +/** + * The SQL inputs for a message search, or null when the query has no token + * long enough to search. Tokens of three or more characters go to the + * trigram index as quoted phrases; shorter ones, and every token when the + * index cannot serve the query, are checked as substrings of the hits. + */ +function buildMessageSearch( + query: string, + hasIndex: boolean, +): Omit | null { + const { long, short } = messageSearchTokens(query); + if (long.length === 0) return null; + // Two-character CJK words are too short for trigrams. + const indexed = hasIndex && long.every((token) => Array.from(token).length >= 3); + return { + match: indexed ? long.map((token) => `"${token.replaceAll('"', '""')}"`).join(" ") : null, + substrings: indexed ? short : [...long, ...short], + }; } -function foldAsciiCase(value: string): string { - return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +// A thread shows a link as its text, so in-thread find cannot reach a match +// in the address; messages are matched as they read. +const MARKDOWN_LINK = /!?\[([^\]\n]*)\]\([^)\s]*\)/g; + +// Result rows show one line, so the match must sit near the start to be seen. +const SNIPPET_LEAD = 24; + +const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff; + +/** + * Up to 240 characters starting a few words before the match at `matchStart`, + * whitespace collapsed, with ellipses where the text was cut. The client finds + * the highlights again in the result. + */ +export function buildSearchSnippet(text: string, matchStart: number): string { + let start = Math.max(0, matchStart - SNIPPET_LEAD); + const wordStart = text.slice(start, matchStart).search(/\s/); + if (start > 0 && wordStart !== -1) start += wordStart + 1; + // Cut between characters, not inside an emoji's surrogate pair. + if (isLowSurrogate(text.charCodeAt(start))) start++; + // Collapsing whitespace shortens the text, so read more of it. + const rawEnd = Math.min(text.length, start + SNIPPET_LENGTH * 2); + // Emphasis and code marks do not show in a thread either. + const shown = text + .slice(start, rawEnd) + .replace(/\*\*|`+/g, "") + .replace(/\s+/g, " ") + .trim(); + let end = Math.min(shown.length, SNIPPET_LENGTH - 2); + if (isLowSurrogate(shown.charCodeAt(end))) end--; + const cut = end < shown.length || rawEnd < text.length; + return `${start > 0 ? "…" : ""}${shown.slice(0, end)}${cut ? "…" : ""}`; } -function buildSearchSnippet(text: string, query: string): string { - const normalizedText = text.replace(/\s+/g, " ").trim(); - if (normalizedText.length <= 240) { - return normalizedText; - } - const normalizedQuery = foldAsciiCase(query.replace(/\s+/g, " ").trim()); - const matchIndex = foldAsciiCase(normalizedText).indexOf(normalizedQuery); - const bodyLength = 236; - const idealStart = Math.max(0, matchIndex - 72); - const start = Math.min(idealStart, normalizedText.length - bodyLength); - const end = Math.min(normalizedText.length, start + bodyLength); - return `${start > 0 ? "…" : ""}${normalizedText.slice(start, end)}${ - end < normalizedText.length ? "…" : "" - }`; +// The newest messages that hold every token, from active, non-subagent +// threads. Every palette and sidebar hides subagent threads, so their +// messages would only use up the hit limit; filters apply before it, so +// hidden threads never crowd out a visible match. Word starts and ranking +// happen in JS, with the rules titles use. +// +// Without the index (an SQLite build without FTS5 trigrams, see migration +// 69) or for two-character CJK words, the same query scans the newest +// messages migration 69 would index. That scan is weaker than the index: it +// ignores case for ASCII only and does not fold accents ("cafe" misses +// "café"). Exported so a test can check the query plan. +export const messageHitsQuery = ( + sql: SqlClient.SqlClient, + { match, substrings, projectId, excludedThreadIds }: typeof SearchRequest.Encoded, +) => sql` + WITH eligible AS MATERIALIZED ( + SELECT threads.thread_id, threads.project_id + FROM orchestration_v2_projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND (${projectId} IS NULL OR threads.project_id = ${projectId}) + AND threads.thread_id NOT IN (SELECT value FROM json_each(${excludedThreadIds})) + AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') + IS NOT 'subagent' + ) + ${ + match === null + ? sql` + SELECT + messages.rowid AS "messageRowid", + messages.message_id AS "messageId", + messages.thread_id AS "threadId", + eligible.project_id AS "projectId", + messages.role AS source, + json_extract(messages.payload_json, '$.text') AS text, + messages.created_at AS "messageCreatedAt" + FROM orchestration_v2_projection_messages AS messages + INNER JOIN eligible ON eligible.thread_id = messages.thread_id + WHERE ${indexedMessageCondition(sql)} + -- A scan reads every message it passes, so it stops at the newest ones. + AND messages.rowid > ( + SELECT coalesce(max(rowid), 0) FROM orchestration_v2_projection_messages + ) - ${MESSAGE_SCAN_LIMIT} + ` + : sql` + SELECT + orchestration_v2_message_search.rowid AS "messageRowid", + messages.message_id AS "messageId", + messages.thread_id AS "threadId", + eligible.project_id AS "projectId", + messages.role AS source, + json_extract(messages.payload_json, '$.text') AS text, + messages.created_at AS "messageCreatedAt" + FROM orchestration_v2_message_search + INNER JOIN orchestration_v2_projection_messages AS messages + ON messages.rowid = orchestration_v2_message_search.rowid + INNER JOIN eligible ON eligible.thread_id = messages.thread_id + WHERE orchestration_v2_message_search MATCH ${match} + AND orchestration_v2_message_search.rowid >= coalesce(( + SELECT rowid FROM orchestration_v2_message_search + WHERE orchestration_v2_message_search MATCH ${match} + ORDER BY rowid DESC + LIMIT 1 OFFSET ${MESSAGE_CANDIDATE_LIMIT - 1} + ), 0) + ` + } + -- Answers to an agent's question show inside its question card, where + -- neither find nor a jump can reach them. + AND messages.message_id NOT LIKE 'async-answer:%' + AND NOT EXISTS ( + SELECT 1 FROM json_each(${substrings}) AS token + WHERE instr(lower(json_extract(messages.payload_json, '$.text')), token.value) = 0 + ) + -- The index's own rowid, so FTS5 returns hits newest first and stops at + -- the limit; ordering by the messages table's rowid sorts every hit. + ORDER BY "messageRowid" DESC + LIMIT ${MESSAGE_HIT_LIMIT} + `; + +const TitleCandidateRow = Schema.Struct({ + thread: Schema.fromJsonString(OrchestrationV2AppThreadJson), + projectTitle: Schema.String, + latestUserMessageAt: Schema.NullOr(IsoDateTime), +}); + +export interface ThreadSearchHit { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly title: string; + readonly source: "title" | OrchestrationThreadSearchSource; + readonly snippet?: string; + readonly messageCreatedAt?: string | null; } export class ThreadSearch extends Context.Service< ThreadSearch, { + /** Message text matches, for clients that rank their own thread list. */ readonly search: ( input: OrchestrationSearchThreadsInput, ) => Effect.Effect; + /** + * Title, branch, pull request, and project matches ranked as the command + * palette ranks them, then message matches for the remaining threads. + */ + readonly searchThreads: ( + input: OrchestrationSearchThreadsInput & { readonly projectId?: ProjectId | undefined }, + ) => Effect.Effect<{ readonly matches: ReadonlyArray }, ThreadSearchError>; } >()("t3/orchestration-v2/ThreadSearch") {} @@ -75,93 +269,223 @@ export const make = Effect.gen(function* () { const searchRows = SqlSchema.findAll({ Request: SearchRequest, Result: SearchRow, - execute: ({ pattern, limit }) => sql` - WITH candidate AS ( - SELECT - threads.thread_id, - threads.project_id, - messages.role, - json_extract(messages.payload_json, '$.text') AS match_text, - messages.created_at AS message_created_at, - messages.message_id, - threads.updated_at AS thread_updated_at - FROM orchestration_v2_projection_messages AS messages - INNER JOIN orchestration_v2_projection_threads AS threads - ON threads.thread_id = messages.thread_id - INNER JOIN projection_projects AS projects - ON projects.project_id = threads.project_id - WHERE threads.deleted_at IS NULL - AND threads.archived_at IS NULL - AND projects.deleted_at IS NULL - AND messages.streaming = 0 - AND messages.role IN ('user', 'assistant') - AND json_extract(messages.payload_json, '$.text') LIKE ${pattern} ESCAPE '!' - ), - ranked AS ( - SELECT - thread_id, - project_id, - role AS source, - match_text, - message_created_at, - CASE role WHEN 'user' THEN 0 ELSE 1 END AS match_rank, - thread_updated_at, - ROW_NUMBER() OVER ( - PARTITION BY thread_id - ORDER BY - CASE role WHEN 'user' THEN 0 ELSE 1 END ASC, - message_created_at DESC, - message_id ASC - ) AS thread_match_rank - FROM candidate - ) + execute: (request) => messageHitsQuery(sql, request), + }); + + const threadOpens = SqlSchema.findAll({ + Request: Schema.Struct({ threadIds: TokenList }), + Result: ThreadOpensRow, + execute: ({ threadIds }) => sql` + SELECT + threads.thread_id AS "threadId", + json_extract(threads.payload_json, '$.opens') AS opens, + ${latestUserMessageAt(sql)} AS "latestUserMessageAt" + FROM orchestration_v2_projection_threads AS threads + WHERE threads.thread_id IN (SELECT value FROM json_each(${threadIds})) + `, + }); + + const titleCandidates = SqlSchema.findAll({ + Request: Schema.Struct({ projectId: Schema.NullOr(ProjectId) }), + Result: TitleCandidateRow, + execute: ({ projectId }) => sql` SELECT - thread_id AS "threadId", - project_id AS "projectId", - source, - match_text AS "matchText", - message_created_at AS "messageCreatedAt" - FROM ranked - WHERE thread_match_rank = 1 - ORDER BY match_rank ASC, thread_updated_at DESC, thread_id ASC - LIMIT ${limit} + threads.payload_json AS thread, + projects.title AS "projectTitle", + ${latestUserMessageAt(sql)} AS "latestUserMessageAt" + FROM orchestration_v2_projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') + IS NOT 'subagent' + AND (${projectId} IS NULL OR threads.project_id = ${projectId}) `, }); - const search: ThreadSearch["Service"]["search"] = Effect.fn("ThreadSearch.search")( - function* (input) { - // The scan reads every message, so run it on the reader worker rather - // than the event loop. - const rows = yield* sql - .withTransaction( - searchRows({ - pattern: `%${escapeLikePattern(input.query)}%`, - limit: input.limit ?? 50, + const read = (effect: Effect.Effect) => + sql.withTransaction(effect).pipe( + NodeSqliteClient.readOnly, + Effect.mapError( + (cause) => + new ThreadSearchError({ + operation: Schema.isSchemaError(cause) ? "decode" : "query", + cause, }), - ) - .pipe( - NodeSqliteClient.readOnly, - Effect.mapError( - (cause) => - new ThreadSearchError({ - operation: Schema.isSchemaError(cause) ? "decode" : "query", - cause, - }), - ), - ); - return { - matches: rows.map((row) => ({ - threadId: row.threadId, - projectId: row.projectId, - source: row.source, - snippet: buildSearchSnippet(row.matchText, input.query), - messageCreatedAt: row.messageCreatedAt, - })), - }; + ), + ); + + // A project scope applies before ranking, so other projects' stronger hits + // cannot use up the limit. + const searchMessages = Effect.fn("ThreadSearch.search")(function* ( + input: OrchestrationSearchThreadsInput & { + readonly projectId: ProjectId | null; + /** Threads the caller already lists, which must not use up the limit. */ + readonly excludedThreadIds?: ReadonlySet; + /** Off for an agent's search, which has no use for the user's picks. */ + readonly picks?: boolean; }, - ); + ) { + // One message per thread: the best word match, then what the user wrote, + // since that is what they remember asking, then the newest by when it was + // written. Row order is not age: imported histories arrive in one batch. + // Text relevance (bm25) would cost a pass over every hit in history. + type Hit = { + readonly row: typeof SearchRow.Type; + /** The message as it reads, links as their text. */ + readonly text: string; + readonly quality: number; + readonly offset: number; + }; + const newer = (left: Hit, right: Hit) => + (right.row.messageCreatedAt ?? "").localeCompare(left.row.messageCreatedAt ?? ""); + const compareHits = (left: Hit, right: Hit) => + right.quality - left.quality || + Number(left.row.source !== "user") - Number(right.row.source !== "user") || + newer(left, right); + const rows = yield* read( + Effect.gen(function* () { + const index = yield* sql` + SELECT 1 FROM sqlite_master WHERE name = 'orchestration_v2_message_search' + `; + const messageSearch = buildMessageSearch(input.query, index.length > 0); + return messageSearch === null + ? [] + : yield* searchRows({ + ...messageSearch, + projectId: input.projectId, + excludedThreadIds: [...(input.excludedThreadIds ?? [])], + }); + }), + ); + const best = new Map(); + // Matching messages per thread at its best quality: a thread that keeps + // using the word ranks above a passing mention, and weaker hits ("Trust" + // for "rust") never add to a whole word's count. + const hitCounts = new Map(); + const hitCount = (threadId: ThreadId) => hitCounts.get(threadId) ?? 1; + const matchMessage = messageMatcher(input.query); + for (const row of rows) { + const text = row.text.replace(MARKDOWN_LINK, "$1"); + const match = matchMessage(text); + if (match === null) continue; + const hit = { row, text, ...match }; + const current = best.get(row.threadId); + if (current === undefined || hit.quality > current.quality) { + hitCounts.set(row.threadId, 1); + } else if (hit.quality === current.quality) { + hitCounts.set(row.threadId, hitCount(row.threadId) + 1); + } + if (current === undefined || compareHits(hit, current) < 0) best.set(row.threadId, hit); + } + // Ranked as clients rank them, with picks and use, before the limit cuts: + // a thread picked for this query must come back even when it only + // matches in a few old messages. + const opens = best.size === 0 ? [] : yield* read(threadOpens({ threadIds: [...best.keys()] })); + const subjectById = new Map( + opens.map((row) => [ + row.threadId, + { + title: "", + activityAt: row.latestUserMessageAt === null ? null : Date.parse(row.latestUserMessageAt), + opens: Option.getOrNull(decodeThreadOpens(row.opens)), + }, + ]), + ); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const ranked = rankMessageMatches( + [...best.values()].map((hit) => ({ + ...hit, + hits: hitCount(hit.row.threadId), + messageCreatedAt: hit.row.messageCreatedAt, + })), + (hit) => subjectById.get(hit.row.threadId), + foldSearchQuery(input.query), + now, + { picks: input.picks ?? true }, + ); + return { + matches: ranked.slice(0, input.limit ?? 50).map(({ row, text, quality, offset }) => ({ + hits: hitCount(row.threadId), + threadId: row.threadId, + messageId: row.messageId, + projectId: row.projectId, + source: row.source, + snippet: buildSearchSnippet(text, offset), + messageCreatedAt: row.messageCreatedAt, + quality, + })), + }; + }); + + const search: ThreadSearch["Service"]["search"] = (input) => + searchMessages({ ...input, projectId: null }); + + const searchThreads: ThreadSearch["Service"]["searchThreads"] = Effect.fn( + "ThreadSearch.searchThreads", + )(function* (input) { + const limit = input.limit ?? 50; + const normalizedQuery = foldSearchQuery(input.query); + const now = DateTime.toEpochMillis(yield* DateTime.now); + // Agents call this rarely, and a project has hundreds of threads, not + // millions, so ranking every active thread in memory stays cheap. + const candidates = yield* read(titleCandidates({ projectId: input.projectId ?? null })); + const subjectById = new Map( + candidates.map((row): [ThreadId, ThreadSearchSubject] => [ + row.thread.id, + { + id: row.thread.id, + title: row.thread.title, + branch: row.thread.branch, + projectTitle: row.projectTitle, + pullRequests: threadSearchPullRequests(row.thread), + // Like the clients: what the user last wrote, else when the thread began. + activityAt: Date.parse( + row.latestUserMessageAt ?? DateTime.formatIso(row.thread.createdAt), + ), + opens: row.thread.opens ?? null, + }, + ]), + ); + const titleHits: ThreadSearchHit[] = rankSearchItems(candidates, (row, options) => + // An agent's search has no use for the user's palette picks. + scoreThreadSearch(normalizedQuery, subjectById.get(row.thread.id)!, now, { + ...options, + picks: false, + }), + ) + .slice(0, limit) + .map(({ item }) => ({ + threadId: item.thread.id, + projectId: item.thread.projectId, + title: item.thread.title, + source: "title", + })); + const shown = new Set(titleHits.map((hit) => hit.threadId)); + const { matches } = yield* searchMessages({ + query: input.query, + limit: 50, + projectId: input.projectId ?? null, + excludedThreadIds: shown, + picks: false, + }); + const messageHits = matches + .filter((match) => subjectById.has(match.threadId)) + .map(({ quality: _quality, hits: _hits, messageId: _messageId, ...match }) => ({ + ...match, + title: subjectById.get(match.threadId)!.title, + })); + // Titles come first, but a fifth of the slots stay for message matches, + // as the palette keeps its "In messages" section. + const messageSlots = Math.min(messageHits.length, Math.floor(limit / 5)); + return { + matches: [...titleHits.slice(0, limit - messageSlots), ...messageHits].slice(0, limit), + }; + }); - return ThreadSearch.of({ search }); + return ThreadSearch.of({ search, searchThreads }); }); export const layer = Layer.effect(ThreadSearch, make); diff --git a/apps/server/src/orchestration-v2/messageSearchIndex.ts b/apps/server/src/orchestration-v2/messageSearchIndex.ts new file mode 100644 index 000000000000..20c7af0b9e11 --- /dev/null +++ b/apps/server/src/orchestration-v2/messageSearchIndex.ts @@ -0,0 +1,68 @@ +import * as Effect from "effect/Effect"; +import type * as SqlClient from "effect/sql/SqlClient"; + +/** + * The messages search indexes: finished, and either written by the user or + * answered by an agent. User-role notices that agents or the system wrote + * stay out. Subagent threads stay out too: search never shows them, and ACP + * subagents rewrite their whole finished answer on every streamed chunk. + * Migration 69's triggers, its backfill, the rebuild, and the scan all use + * it; `row` names the message row (a table alias, or `new` in a trigger). + */ +export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "messages") => + sql.literal(` + ${row}.streaming = 0 + AND ( + ${row}.role = 'assistant' + OR ( + ${row}.role = 'user' + AND COALESCE(json_extract(${row}.payload_json, '$.createdBy'), 'user') = 'user' + ) + ) + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_threads AS subagent_threads + WHERE subagent_threads.thread_id = ${row}.thread_id + AND json_extract(subagent_threads.payload_json, '$.lineage.relationshipToParent') = 'subagent' + ) +`); + +/** + * What the index stores for a message: its text with curly apostrophes + * straight, as search folds a query, since iOS keyboards type "’" and others + * type "'". Migration 69's triggers use it too. `payload` is the message's + * JSON column. + */ +export const indexedMessageText = (sql: SqlClient.SqlClient, payload: string) => + sql.literal(`replace(replace(json_extract(${payload}, '$.text'), '’', ''''), '‘', '''')`); + +/** + * Refills the search index from the messages table. The index is keyed by + * message rowid, so anything that may renumber or copy rows (VACUUM, a dev + * database copy) rebuilds it instead of trusting it. Databases from before + * migration 69, or on an SQLite without FTS5 trigrams, have no index. + */ +export const rebuildMessageSearch = Effect.fn("MessageSearchIndex.rebuild")(function* ( + sql: SqlClient.SqlClient, +) { + const index = yield* sql` + SELECT 1 FROM sqlite_master WHERE name = 'orchestration_v2_message_search' + `; + if (index.length === 0) return; + // One transaction: an interrupt between clear and refill would leave an + // empty index that nothing repairs, since search only checks the table exists. + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql` + INSERT INTO orchestration_v2_message_search (orchestration_v2_message_search) + VALUES ('delete-all') + `; + yield* sql` + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT messages.rowid, ${indexedMessageText(sql, "messages.payload_json")} + FROM orchestration_v2_projection_messages AS messages + WHERE ${indexedMessageCondition(sql)} + AND json_extract(messages.payload_json, '$.text') IS NOT NULL + `; + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 1c9404da6b92..9a7724922ba3 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5543,6 +5543,76 @@ it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { ); }); +it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("thread opens", (it) => { + it.effect( + "counts opens on the server clock, merges quick reopens, and keeps picked queries", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-opened-thread"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-opened-thread-create"), + threadId, + projectId: ProjectId.make("runtime-layer-opened-project"), + title: "Opened thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + let visits = 0; + const visit = (input: { readonly opened?: boolean; readonly searchQuery?: string }) => + orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make(`runtime-layer-opened-visit-${++visits}`), + threadId, + // A client clock far in the past must not move the open time. + visitedAt: "2000-01-01T00:00:00.000Z", + ...input, + }); + const opens = Effect.map( + orchestrator.getShellSnapshot(), + (shell) => shell.threads.find((thread) => thread.id === threadId)?.opens ?? null, + ); + const start = DateTime.toEpochMillis(yield* DateTime.now); + + yield* visit({ opened: true, searchQuery: " Upl " }); + assert.deepEqual(yield* opens, { + openedAt: start, + score: 1, + picks: [{ query: "upl", at: start }], + }); + + yield* TestClock.adjust("1 minute"); + yield* visit({ opened: true, searchQuery: "Uploadthing docs" }); + assert.deepEqual(yield* opens, { + openedAt: start, + score: 1, + picks: [ + { query: "uploadthing docs", at: start + 60_000 }, + { query: "upl", at: start }, + ], + }); + + yield* TestClock.adjust("14 days"); + yield* visit({}); + yield* visit({ opened: true }); + const later = yield* opens; + assert.closeTo(later!.score, 1 + 2 ** (-(14 * 24 * 60 + 1) / (14 * 24 * 60)), 1e-9); + assert.equal(later!.openedAt, DateTime.toEpochMillis(yield* DateTime.now)); + // Opening without a query keeps each pick and its time. + assert.deepEqual(later!.picks, [ + { query: "uploadthing docs", at: start + 60_000 }, + { query: "upl", at: start }, + ]); + }), + ); +}); + it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", (it) => { it.effect("persists only the changed PR link and replays it without losing other links", () => Effect.gen(function* () { diff --git a/apps/server/src/persistence/DatabaseMaintenance.test.ts b/apps/server/src/persistence/DatabaseMaintenance.test.ts index 363256120a55..3baadaaed33b 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.test.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.test.ts @@ -98,6 +98,20 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer))) yield* sql`CREATE TABLE discarded_data (value BLOB)`; yield* sql`INSERT INTO discarded_data VALUES (zeroblob(4194304))`; yield* sql`DROP TABLE discarded_data`; + yield* sql` + INSERT INTO orchestration_v2_projection_messages ( + message_id, thread_id, run_id, node_id, role, streaming, created_at, updated_at, payload_json + ) + VALUES ( + 'message-1', 'thread-1', NULL, NULL, 'user', 0, + '2026-09-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z', '{"text":"needle"}' + ) + `; + // An index that drifted from its rows, as a renumbering VACUUM would leave it. + yield* sql` + INSERT INTO orchestration_v2_message_search (orchestration_v2_message_search) + VALUES ('delete-all') + `; yield* sql`PRAGMA wal_checkpoint(TRUNCATE)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath }))); @@ -118,6 +132,16 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer))) const sql = yield* SqlClient.SqlClient; assert.deepEqual(yield* sql`PRAGMA auto_vacuum`, [{ auto_vacuum: 2 }]); assert.deepEqual(yield* sql`SELECT value FROM retained_data`, [{ value: "keep" }]); + assert.deepEqual( + yield* sql` + SELECT messages.message_id AS "messageId" + FROM orchestration_v2_message_search AS search + INNER JOIN orchestration_v2_projection_messages AS messages + ON messages.rowid = search.rowid + WHERE orchestration_v2_message_search MATCH '"needle"' + `, + [{ messageId: "message-1" }], + ); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath, readonly: true }))); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/persistence/DatabaseMaintenance.ts b/apps/server/src/persistence/DatabaseMaintenance.ts index 72af08b04c86..3c82975e3d4c 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.ts @@ -11,6 +11,7 @@ import { isSqlError } from "effect/sql/SqlError"; import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import { rebuildMessageSearch } from "../orchestration-v2/messageSearchIndex.ts"; export class DatabaseMissingError extends Schema.TaggedError()( "DatabaseMissingError", @@ -194,6 +195,7 @@ const make = Effect.gen(function* () { // database to incremental auto_vacuum; the server reclaims online after. yield* sql`PRAGMA auto_vacuum = INCREMENTAL`; yield* sql`VACUUM`; + yield* rebuildMessageSearch(sql); yield* sql`PRAGMA wal_checkpoint(TRUNCATE)`; return summary; }).pipe( diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 5b8ca15241ba..380d6d2807bd 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -71,6 +71,7 @@ import Migration0065 from "./Migrations/065_CodexUsageNativeTurnIndex.ts"; import Migration0066 from "./Migrations/066_RepairClientSettlementTimestamps.ts"; import Migration0067 from "./Migrations/067_WebhookDedupe.ts"; import Migration0068 from "./Migrations/068_ShellStopRequestIndex.ts"; +import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -142,6 +143,7 @@ const migrationEntries = [ [66, "RepairClientSettlementTimestamps", Migration0066], [67, "WebhookDedupe", Migration0067], [68, "ShellStopRequestIndex", Migration0068], + [69, "ThreadMessageSearch", Migration0069], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts new file mode 100644 index 000000000000..029218bb67b1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts @@ -0,0 +1,55 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; + +const layer = it.layer(NodeSqliteClient.layer({ filename: ":memory:" })); + +layer("069_ThreadMessageSearch", (it) => { + it.effect("backfills what users wrote and agents answered, then follows deletes", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 68 }); + const insert = ( + id: string, + role: string, + streaming: number, + text: string, + createdBy: string | undefined = role === "user" ? "user" : "agent", + ) => sql` + INSERT INTO orchestration_v2_projection_messages ( + message_id, thread_id, run_id, node_id, role, streaming, created_at, updated_at, payload_json + ) + VALUES ( + ${id}, 'thread-1', NULL, NULL, ${role}, ${streaming}, + '2026-09-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z', ${JSON.stringify({ text, createdBy })} + ) + `; + yield* insert("user", "user", 0, "needle from the user"); + yield* insert("assistant", "assistant", 0, "needle from the agent"); + yield* insert("streaming", "assistant", 1, "needle still streaming"); + yield* insert("system", "system", 0, "needle in a system prompt"); + yield* insert("notice", "user", 0, "needle in a pull request update", "agent"); + // Older rows carry no author; they were the user's. + yield* insert("legacy", "user", 0, "needle from before authors", undefined); + + yield* runMigrations(); + + const indexed = () => + sql<{ readonly messageId: string }>` + SELECT messages.message_id AS "messageId" + FROM orchestration_v2_message_search AS search + INNER JOIN orchestration_v2_projection_messages AS messages + ON messages.rowid = search.rowid + WHERE orchestration_v2_message_search MATCH '"needle"' + ORDER BY messages.message_id + `.pipe(Effect.map((rows) => rows.map((row) => row.messageId))); + assert.deepEqual(yield* indexed(), ["assistant", "legacy", "user"]); + + yield* sql`DELETE FROM orchestration_v2_projection_messages WHERE message_id = 'user'`; + assert.deepEqual(yield* indexed(), ["assistant", "legacy"]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts new file mode 100644 index 000000000000..f40eafeb8f00 --- /dev/null +++ b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts @@ -0,0 +1,106 @@ +import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { + indexedMessageCondition, + indexedMessageText, +} from "../../orchestration-v2/messageSearchIndex.ts"; + +/** + * Full-text index for thread message search. The trigram tokenizer matches any + * substring of three or more characters, ignoring case and accents, so + * identifiers inside code still match, without the full-table scan a + * `LIKE '%q%'` needs. + * + * It holds what the user wrote and what agents answered. User-role messages + * that agents or the system wrote (pull request updates, task notices) would + * flood results and read as "You". + * + * The index is contentless and keyed by the message rowid: search reads the + * text back from the message row, so the text is not stored twice. `t3 doctor` + * rebuilds it after its VACUUM, since SQLite does not promise to keep rowids. + * Thread search scans the same messages when the index is missing. + * + * Triggers keep it in sync with every write to the messages table. Streaming + * messages are indexed once they finish, with apostrophes folded as search + * folds them. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Node and Electron bundle a SQLite with these options; a Node built against + // an older system SQLite may lack them. Search then scans messages instead + // of failing to start. Only SQLITE_ERROR ("no such module", "no such + // tokenizer", "unrecognized option") means that: a full disk or I/O error + // fails the migration, so the next start retries instead of skipping the + // index for good. + const created = yield* sql` + CREATE VIRTUAL TABLE orchestration_v2_message_search USING fts5( + text, + content = '', + contentless_delete = 1, + tokenize = 'trigram remove_diacritics 1' + ) + `.pipe( + Effect.as(true), + Effect.catchIf( + (cause) => + Predicate.hasProperty(cause.reason.cause, "errcode") && cause.reason.cause.errcode === 1, + (cause) => + Effect.logWarning( + "Message search index unavailable; search will scan messages.", + cause, + ).pipe(Effect.as(false)), + ), + ); + if (!created) return; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_insert + AFTER INSERT ON orchestration_v2_projection_messages + WHEN ${indexedMessageCondition(sql, "new")} + BEGIN + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} + WHERE json_extract(new.payload_json, '$.text') IS NOT NULL; + END + `; + // Streaming deltas rewrite the row many times a second; skip them until the + // message settles. Upserts rewrite every column, so a finished message + // re-indexes only when what the index reads changed. Deleting a rowid the + // index never held is a no-op. + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_update + AFTER UPDATE OF payload_json, streaming, role ON orchestration_v2_projection_messages + WHEN (old.streaming = 0 OR new.streaming = 0) + AND ( + old.streaming IS NOT new.streaming + OR old.role IS NOT new.role + OR json_extract(old.payload_json, '$.text') IS NOT json_extract(new.payload_json, '$.text') + OR json_extract(old.payload_json, '$.createdBy') + IS NOT json_extract(new.payload_json, '$.createdBy') + ) + BEGIN + DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid AND old.streaming = 0; + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} + WHERE ${indexedMessageCondition(sql, "new")} + AND json_extract(new.payload_json, '$.text') IS NOT NULL; + END + `; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_delete + AFTER DELETE ON orchestration_v2_projection_messages + WHEN old.streaming = 0 + BEGIN + DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid; + END + `; + yield* sql` + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT messages.rowid, ${indexedMessageText(sql, "messages.payload_json")} + FROM orchestration_v2_projection_messages AS messages + WHERE ${indexedMessageCondition(sql)} + AND json_extract(messages.payload_json, '$.text') IS NOT NULL + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 64f1d9e3ae2f..e5a9b86fccbb 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -48,6 +48,7 @@ describe("V2 preview upgrade", () => { [66, "RepairClientSettlementTimestamps"], [67, "WebhookDedupe"], [68, "ShellStopRequestIndex"], + [69, "ThreadMessageSearch"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -139,6 +140,7 @@ describe("V2 preview upgrade", () => { [66, "RepairClientSettlementTimestamps"], [67, "WebhookDedupe"], [68, "ShellStopRequestIndex"], + [69, "ThreadMessageSearch"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index a81455c56f4c..dfa8b1951c10 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -112,6 +112,26 @@ function makeThread(overrides: Partial = {}): Thread { } describe("buildThreadActionItems", () => { + it("keeps threads apart when two environments share a thread ID", async () => { + const local = makeThread(); + const remote = makeThread({ environmentId: EnvironmentId.make("remote-copy") }); + const runThread = vi.fn(async () => {}); + const items = buildThreadActionItems({ + threads: [local, remote], + activeThread: remote, + projectByKey: new Map([ + [`${local.environmentId}:${local.projectId}`, { title: "Local project" }], + [`${remote.environmentId}:${remote.projectId}`, { title: "Remote project" }], + ]), + icon: null, + runThread, + }); + expect(new Set(items.map((item) => item.value)).size).toBe(2); + const remoteItem = items.find((item) => item.description === "Remote project · Current thread"); + await remoteItem?.run(); + expect(runThread).toHaveBeenCalledWith(remote); + }); + it("ranks thread title matches ahead of contextual project-name matches", () => { const threadItems = buildThreadActionItems({ threads: [ @@ -127,8 +147,7 @@ describe("buildThreadActionItems", () => { updatedAt: "2026-03-19T00:00:00.000Z", }), ], - projectTitleById: new Map([[PROJECT_ID, "Project"]]), - sortOrder: "updated_at", + projectByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, { title: "Project" }]]), icon: null, runThread: async (_thread) => undefined, }); @@ -144,57 +163,250 @@ describe("buildThreadActionItems", () => { expect(groups).toHaveLength(1); expect(groups[0]?.value).toBe("threads-search"); expect(groups[0]?.items.map((item) => item.value)).toEqual([ - "thread:thread-title-match", - "thread:thread-context-match", + "thread:environment-local:thread-title-match", + "thread:environment-local:thread-context-match", ]); }); - it("orders title matches by recent activity before older prefix matches", () => { + it("lists message matches last, once, without moving the title matches above them", () => { const threads = [ - makeThread({ - id: ThreadId.make("old-prefix"), - title: "Convex InvalidCursor in Convex threads query", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-02T00:00:00.000Z", - }), - makeThread({ - id: ThreadId.make("recent-title"), - title: "Disable Convex schema validation", - createdAt: "2025-12-01T00:00:00.000Z", - updatedAt: "2026-03-24T00:00:00.000Z", - }), - makeThread({ - id: ThreadId.make("recent-content"), - title: "Fix schema validation", - createdAt: "2026-03-25T00:00:00.000Z", - updatedAt: "2026-03-25T00:00:00.000Z", - }), + makeThread({ id: ThreadId.make("title"), title: "Disable Convex schema validation" }), + makeThread({ id: ThreadId.make("message"), title: "Fix schema validation" }), ]; const items = buildThreadActionItems({ threads, - projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), - sortOrder: "created_at", + projectByKey: new Map(), icon: null, - getContentMatch: (thread) => - thread.id === ThreadId.make("recent-content") - ? { source: "user", snippet: "Please check Convex", query: "convex" } - : undefined, runThread: async () => undefined, }); + const filter = (threadMessageItems: typeof items) => + filterCommandPaletteGroups({ + activeGroups: [], + query: "convex", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + threadMessageItems, + }).map((group) => [group.label, group.items.map((item) => item.value)]); + + const before = filter([]); + // The server matched both; the title match stays where it was. + const after = filter([items[0]!, items[1]!].toReversed()); + expect(before).toEqual([["Threads", ["thread:environment-local:title"]]]); + expect(after).toEqual([ + ["Threads", ["thread:environment-local:title"]], + ["In messages", ["thread:environment-local:message"]], + ]); + }); + it("keeps an active thread first while its title is typed, above settings and actions", () => { + const now = Date.parse("2026-10-23T00:00:00.000Z"); + const action = (value: string, title: string, searchTerms: string[] = [title]) => ({ + kind: "action" as const, + value, + searchTerms, + title, + icon: null, + run: async () => undefined, + }); + const threadSearchItems = buildThreadActionItems({ + threads: [ + makeThread({ + title: "Compaction prompt timing and cache age", + latestUserMessageAt: "2026-10-11T00:00:00.000Z", + }), + ], + projectByKey: new Map(), + icon: null, + runThread: async () => undefined, + }); + const firstGroup = (query: string) => + filterCommandPaletteGroups({ + activeGroups: [ + { value: "actions", label: "Actions", items: [action("action:copy", "Copy thread ID")] }, + ], + query, + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [ + action("setting:compact", "Compact idle threads"), + action("setting:providers", "Providers", ["Providers", "compatibility"]), + ], + threadSearchItems, + now, + })[0]?.label; + + for (const query of ["comp", "compa", "compac", "compact", "compacti", "compaction"]) { + expect(firstGroup(query), query).toBe("Threads"); + } + }); + + it("orders groups by their best match, so a strong thread is not under a weak action", () => { + const action = (value: string, title: string) => ({ + kind: "action" as const, + value, + searchTerms: [title], + title, + icon: null, + run: async () => undefined, + }); + const threadSearchItems = buildThreadActionItems({ + threads: [makeThread({ title: "Upgrade to pnpm 12" })], + projectByKey: new Map(), + icon: null, + runThread: async () => undefined, + }); + const labels = (query: string) => + filterCommandPaletteGroups({ + activeGroups: [ + { value: "actions", label: "Actions", items: [action("action:upload", "Upload file")] }, + ], + query, + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems, + }).map((group) => group.label); + + expect(labels("upgrade")).toEqual(["Threads"]); + expect(labels("upl")).toEqual(["Actions"]); + // A short query still finds a command by a keyword it equals. + const prGroups = filterCommandPaletteGroups({ + activeGroups: [ + { + value: "actions", + label: "Actions", + items: [ + { + kind: "action", + value: "action:open-prs", + searchTerms: ["Open pull requests", "pr", "prs", "review"], + title: "Open pull requests", + icon: null, + run: async () => undefined, + }, + action("action:project", "Project settings"), + ], + }, + ], + query: "pr", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + }); + expect(prGroups.flatMap((group) => group.items.map((item) => item.value))).toEqual([ + "action:open-prs", + "action:project", + ]); + expect(labels("up")).toEqual(["Actions", "Threads"]); + expect(labels("pnpm up")).toEqual(["Threads"]); + }); + + it("runs the command for its whole name over a thread with that title, and lists the open thread last", () => { + const threads = [ + makeThread({ id: ThreadId.make("thread-new"), title: "New thread" }), + makeThread({ id: ThreadId.make("thread-open"), title: "New thread layout" }), + makeThread({ id: ThreadId.make("thread-other"), title: "New thread button" }), + ]; + const threadSearchItems = buildThreadActionItems({ + threads, + activeThread: threads[1]!, + projectByKey: new Map(), + icon: null, + runThread: async () => undefined, + }); const groups = filterCommandPaletteGroups({ - activeGroups: [], - query: "convex", + activeGroups: [ + { + value: "actions", + label: "Actions", + items: [ + { + kind: "action", + value: "action:new-thread", + searchTerms: ["New thread in t3code"], + title: "New thread in t3code", + icon: null, + run: async () => undefined, + }, + ], + }, + ], + query: "new thread", isInSubmenu: false, projectSearchItems: [], - threadSearchItems: items, + threadSearchItems, }); + expect(groups.map((group) => group.label)).toEqual(["Actions", "Threads"]); + expect(groups[1]?.items.at(-1)?.title).toBe("New thread layout"); + }); - expect(groups[0]?.items.map((item) => item.value)).toEqual([ - "thread:recent-title", - "thread:old-prefix", - "thread:recent-content", - ]); + it("lists a project above threads that only hold its name when the name is typed", () => { + const threadSearchItems = buildThreadActionItems({ + threads: [makeThread({ title: "Lakebed performance gains" })], + projectByKey: new Map(), + icon: null, + runThread: async () => undefined, + }); + const projectSearchItems = [ + { + kind: "action" as const, + value: "project:lakebed", + searchTerms: ["Lakebed", "/code/lakebed"], + title: "Lakebed", + icon: null, + run: async () => undefined, + }, + ]; + const labels = (query: string) => + filterCommandPaletteGroups({ + activeGroups: [], + query, + isInSubmenu: false, + projectSearchItems, + threadSearchItems, + }).map((group) => group.label); + expect(labels("lakebed")).toEqual(["Projects", "Threads"]); + expect(labels("lake")).toEqual(["Threads", "Projects"]); + }); + + it("keeps a command first over a thread in daily use that matches as well", () => { + const now = Date.parse("2026-10-23T00:00:00.000Z"); + const threadSearchItems = buildThreadActionItems({ + threads: [ + makeThread({ + title: "New onboarding flow", + latestUserMessageAt: "2026-10-22T00:00:00.000Z", + opens: { openedAt: Date.parse("2026-10-22T00:00:00.000Z"), score: 7, picks: [] }, + }), + ], + projectByKey: new Map(), + icon: null, + runThread: async () => undefined, + }); + const groups = filterCommandPaletteGroups({ + activeGroups: [ + { + value: "actions", + label: "Actions", + items: [ + { + kind: "action", + value: "action:new-thread", + searchTerms: ["new thread", "chat"], + title: "New thread", + icon: null, + run: async () => undefined, + }, + ], + }, + ], + query: "new", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems, + now, + }); + expect(groups.map((group) => group.label)).toEqual(["Actions", "Threads"]); }); it("ranks an order-independent setting title match above a split context match", () => { @@ -234,6 +446,59 @@ describe("buildThreadActionItems", () => { ]); }); + it("searches a styled command title by its plain text", () => { + const groups = filterCommandPaletteGroups({ + activeGroups: [ + { + value: "actions", + label: "Actions", + items: [ + { + kind: "action", + value: "action:new-thread", + searchTerms: ["new thread", "chat"], + // Styled: not a string, so only `searchTitle` holds its words. + title: ["New thread in ", "t3code"], + searchTitle: "New thread in t3code", + icon: null, + run: async () => undefined, + }, + ], + }, + ], + query: "> new t3code", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + }); + expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([ + "action:new-thread", + ]); + }); + + it("forgives a typo in a setting name when nothing matches exactly", () => { + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "keybindngs", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + settingsSearchItems: [ + { + kind: "action", + value: "setting:keybindings", + title: "Keybindings", + searchTerms: ["Keybindings", "General", "shortcuts"], + icon: null, + run: async () => undefined, + }, + ], + }); + expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([ + "setting:keybindings", + ]); + }); + it("normalizes case independently of the host locale", () => { const toLocaleLowerCase = String.prototype.toLocaleLowerCase; const localeLowerCase = vi @@ -280,8 +545,7 @@ describe("buildThreadActionItems", () => { }); const items = buildThreadActionItems({ threads: [idThread, titleThread], - projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), - sortOrder: "updated_at", + projectByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, { title: "T3 Code" }]]), icon: null, runThread: async (_thread) => undefined, }); @@ -296,8 +560,8 @@ describe("buildThreadActionItems", () => { }); expect(groups.flatMap((group) => group.items)).toEqual([ - expect.objectContaining({ value: `thread:${titleThread.id}` }), - expect.objectContaining({ value: `thread:${idThread.id}` }), + expect.objectContaining({ value: `thread:${LOCAL_ENVIRONMENT_ID}:${titleThread.id}` }), + expect.objectContaining({ value: `thread:${LOCAL_ENVIRONMENT_ID}:${idThread.id}` }), ]); }); @@ -325,13 +589,15 @@ describe("buildThreadActionItems", () => { }, }), ], - projectTitleById: new Map(), - sortOrder: "updated_at", + projectByKey: new Map(), icon: null, runThread: async () => undefined, }); - expect(items.map((item) => item.value).toSorted()).toEqual(["thread:fork", "thread:parent"]); + expect(items.map((item) => item.value).toSorted()).toEqual([ + "thread:environment-local:fork", + "thread:environment-local:parent", + ]); }); it("ranks word-start title matches above newer mid-word matches", () => { @@ -354,8 +620,7 @@ describe("buildThreadActionItems", () => { updatedAt: "2026-03-01T00:00:00.000Z", }), ], - projectTitleById: new Map(), - sortOrder: "updated_at", + projectByKey: new Map(), icon: null, runThread: async () => undefined, }); @@ -369,13 +634,13 @@ describe("buildThreadActionItems", () => { }); expect(groups[0]?.items.map((item) => item.value)).toEqual([ - "thread:word-start", - "thread:branch-word-start", - "thread:mid-word", + "thread:environment-local:word-start", + "thread:environment-local:branch-word-start", + "thread:environment-local:mid-word", ]); }); - it("breaks ties within a match tier by latest user message", () => { + it("breaks ties between equal matches by latest user message", () => { const items = buildThreadActionItems({ threads: [ makeThread({ @@ -391,8 +656,7 @@ describe("buildThreadActionItems", () => { latestUserMessageAt: "2026-03-10T00:00:00.000Z", }), ], - projectTitleById: new Map(), - sortOrder: "updated_at", + projectByKey: new Map(), icon: null, runThread: async () => undefined, }); @@ -403,11 +667,12 @@ describe("buildThreadActionItems", () => { isInSubmenu: false, projectSearchItems: [], threadSearchItems: items, + now: Date.parse("2026-03-20T00:00:00.000Z"), }); expect(groups[0]?.items.map((item) => item.value)).toEqual([ - "thread:word-fresh", - "thread:prefix-stale", + "thread:environment-local:word-fresh", + "thread:environment-local:prefix-stale", ]); }); }); @@ -457,8 +722,7 @@ it.each([ }), makeThread({ id: ThreadId.make("unrelated"), title: "Other work" }), ], - projectTitleById: new Map(), - sortOrder: "updated_at", + projectByKey: new Map(), icon: null, runThread: async () => undefined, }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ce9d335b4d08..b7d02359d416 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,15 @@ -import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; +import { + foldSearchQuery, + PALETTE_SECONDARY_LIMIT, + PALETTE_THREAD_LIMIT, + rankSearchGroups, + scoreSearchTerms, + scoreThreadSearch, + type SearchMatchHint, + type SearchRange, + type ThreadSearchSubject, +} from "@t3tools/shared/paletteSearch"; +import { threadSearchSubject } from "@t3tools/client-runtime/state/thread-search"; import type { CommandPaletteLinkedThreads } from "../commandPaletteBus"; import { addProjectRemoteSourceLabel, @@ -17,12 +28,7 @@ import { THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem"; -import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import * as Arr from "effect/Array"; -import * as Result from "effect/Result"; import { type ReactNode } from "react"; -import { getThreadSortTimestamp, sortThreads } from "../lib/threadSort"; -import { normalizeSearchText } from "../lib/utils"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; import { isSidebarSubagentThread } from "./Sidebar.logic"; @@ -155,10 +161,17 @@ export interface CommandPaletteItem { readonly value: string; readonly searchTerms: ReadonlyArray; readonly title: ReactNode; + /** The title as plain text, for searching, when `title` is styled. */ + readonly searchTitle?: string; readonly description?: ReactNode; readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; - readonly searchRecency?: number; + /** Thread rows rank by this, with use history, instead of by `searchTerms`. */ + readonly threadSearch?: ThreadSearchSubject; + /** Title ranges the current query matched, for highlighting. */ + readonly titleRanges?: ReadonlyArray; + /** Shown in place of the description when the match is not in the title. */ + readonly matchHint?: SearchMatchHint; readonly icon: ReactNode; readonly disabled?: boolean; readonly titleLeadingContent?: ReactNode; @@ -360,36 +373,37 @@ export type BuildThreadActionItemsThread = Pick< | "runtime" | "title" | "worktreePath" -> & { - pullRequests?: SidebarThreadSummary["pullRequests"]; - updatedAt: string; - latestUserMessageAt?: string | null; -}; +> & + Partial< + Pick< + SidebarThreadSummary, + "pullRequests" | "linkedPullRequest" | "branchPullRequest" | "opens" | "latestUserMessageAt" + > + > & { + updatedAt: string; + }; export function buildThreadActionItems(input: { threads: ReadonlyArray; - activeThreadId?: Thread["id"]; - projectTitleById: ReadonlyMap; - sortOrder: SidebarThreadSortOrder; + activeThread?: Pick; + projectByKey: ReadonlyMap>; icon: ReactNode; renderLeadingContent?: (thread: TThread) => ReactNode; renderTrailingContent?: (thread: TThread) => ReactNode; renderDescription?: (thread: TThread, meta: { projectTitle: string | undefined }) => ReactNode; - getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined; - runThread: (thread: Pick) => Promise; - limit?: number; + runThread: (thread: TThread) => Promise; + /** Makes the environment searchable; pass it only with two or more environments. */ + environmentLabelOf?: (thread: TThread) => string | null; }): CommandPaletteActionItem[] { - const sortedThreads = sortThreads( - input.threads.filter( - (thread) => thread.archivedAt === null && !isSidebarSubagentThread(thread), - ), - input.sortOrder, + // Callers order these: recents by last use, search results by rank. + const visibleThreads = input.threads.filter( + (thread) => thread.archivedAt === null && !isSidebarSubagentThread(thread), ); - const visibleThreads = - input.limit === undefined ? sortedThreads : sortedThreads.slice(0, input.limit); return visibleThreads.map((thread) => { - const projectTitle = input.projectTitleById.get(thread.projectId); + const projectTitle = input.projectByKey.get( + `${thread.environmentId}:${thread.projectId}`, + )?.title; const descriptionParts: string[] = []; if (projectTitle) { @@ -398,13 +412,15 @@ export function buildThreadActionItems { await input.runThread(thread); @@ -441,60 +459,54 @@ export function buildThreadActionItems, -): number { - const normalizedField = normalizeSearchText(field); - if ( - normalizedField.length === 0 || - !queryTokens.every((token) => normalizedField.includes(token)) - ) { - return Number.NEGATIVE_INFINITY; - } - if (normalizedField === normalizedQuery) { - return 3; - } - if (normalizedField.startsWith(normalizedQuery)) { - return 2; - } - if (queryTokens.every((token) => startsWord(normalizedField, token))) { - return 1; - } - return 0; -} - -// A word-start match in any field beats a mid-word match, then earlier fields win. -// Thread titles collapse prefix and word-start into one tier so recency picks between them. -function rankCommandPaletteItemMatch( - item: CommandPaletteActionItem | CommandPaletteSubmenuItem, + now: number, + options: { readonly typos: boolean }, +): { + readonly score: number; + readonly quality: number; + readonly groupQuality?: number; + readonly strong: boolean; + readonly ranges: ReadonlyArray; + readonly explain?: () => SearchMatchHint | undefined; + readonly pickedAt?: number; + readonly pickedExactly?: boolean; +} | null { + const match = scorePaletteItem(item, normalizedQuery, now, options); + // A row listed last in its group (a keybinding setting, the open thread) + // does not decide where the group sits. + return match && item.secondary ? { ...match, groupQuality: 0 } : match; +} + +function scorePaletteItem( + item: PaletteItem, normalizedQuery: string, - queryTokens: ReadonlyArray, -): number { - let best = 0; - for (const [index, field] of item.searchTerms.filter((term) => term.length > 0).entries()) { - const fieldRank = rankSearchFieldMatch(field, normalizedQuery, queryTokens); - if (fieldRank === Number.NEGATIVE_INFINITY) continue; - const fieldScore = - index === 0 && item.searchRecency !== undefined - ? 1_000 + Number(fieldRank === 3) - : 1_000 - index * 100 + fieldRank; - best = Math.max(best, (fieldRank > 0 ? 10_000 : 0) + fieldScore); - } - return best; -} - + now: number, + options: { readonly typos: boolean }, +) { + if (item.threadSearch) return scoreThreadSearch(normalizedQuery, item.threadSearch, now, options); + // The title names the item; search terms are keywords. Ranges index the + // first term, so they only highlight a title that is that term. + const name = typeof item.title === "string" ? item.title : item.searchTitle; + const terms = + name !== undefined && name !== item.searchTerms[0] + ? [name, ...item.searchTerms] + : item.searchTerms; + const match = scoreSearchTerms(normalizedQuery, terms, options); + return match && (item.title === terms[0] ? match : { ...match, ranges: [] }); +} + +/** + * Filters and ranks the palette for a query. Groups order by the class of their + * best match, and on equal classes commands come first, then threads, so "new" + * runs "New thread" and a thread's title is not stuck under a setting. Server + * message matches always come last, in their own group, so rows above never + * move when they arrive. + */ export function filterCommandPaletteGroups(input: { activeGroups: ReadonlyArray; query: string; @@ -502,10 +514,13 @@ export function filterCommandPaletteGroups(input: { projectSearchItems: ReadonlyArray; settingsSearchItems?: ReadonlyArray; threadSearchItems: ReadonlyArray; + /** Threads whose messages match, best first; threads already listed are skipped. */ + threadMessageItems?: ReadonlyArray; + now?: number; }): CommandPaletteGroup[] { const isActionsFilter = input.query.startsWith(">"); const searchQuery = isActionsFilter ? input.query.slice(1) : input.query; - const normalizedQuery = normalizeSearchText(searchQuery); + const normalizedQuery = foldSearchQuery(searchQuery); if (normalizedQuery.length === 0) { if (isActionsFilter) { @@ -513,7 +528,6 @@ export function filterCommandPaletteGroups(input: { } return [...input.activeGroups]; } - const queryTokens = normalizedQuery.split(" "); let baseGroups = [...input.activeGroups]; if (isActionsFilter) { @@ -523,7 +537,17 @@ export function filterCommandPaletteGroups(input: { } const searchableGroups = [...baseGroups]; - if (!input.isInSubmenu && !isActionsFilter) { + const searchesEverything = !input.isInSubmenu && !isActionsFilter; + // On equal match classes, groups keep this order: threads, what people search + // for most, right after commands. + if (searchesEverything) { + if (input.threadSearchItems.length > 0) { + searchableGroups.push({ + value: "threads-search", + label: "Threads", + items: input.threadSearchItems, + }); + } if (input.projectSearchItems.length > 0) { searchableGroups.push({ value: "projects-search", @@ -538,43 +562,45 @@ export function filterCommandPaletteGroups(input: { items: input.settingsSearchItems, }); } - if (input.threadSearchItems.length > 0) { - searchableGroups.push({ - value: "threads-search", - label: "Threads", - items: input.threadSearchItems, - }); - } } - return searchableGroups.flatMap((group) => { - const items = Arr.filterMap(group.items, (item, index) => { - const haystack = normalizeSearchText(item.searchTerms.join(" ")); - if (!queryTokens.every((token) => haystack.includes(token))) { - return Result.failVoid; - } - - return Result.succeed({ - item, - index, - rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens), - }); - }) + const now = input.now ?? Date.now(); + const ranked = rankSearchGroups( + searchableGroups.map((group) => ({ group, items: group.items })), + (item, options) => rankPaletteItem(item, normalizedQuery, now, options), + ); + const threadsMatch = ranked.some(({ group }) => group.value === "threads-search"); + const groups = ranked.map(({ group, ranked }): CommandPaletteGroup => { + // Secondary items stay below the rest; the sort is stable, so score order holds. + const limit = + group.value === "threads-search" + ? PALETTE_THREAD_LIMIT + : searchesEverything && threadsMatch + ? PALETTE_SECONDARY_LIMIT + : ranked.length; + // Hints are built only for the rows shown. + const items = ranked .toSorted( (left, right) => - Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) || - right.rank - left.rank || - (right.item.searchRecency ?? 0) - (left.item.searchRecency ?? 0) || - left.index - right.index, + Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false), ) - .map((entry) => entry.item); - - if (items.length === 0) { - return []; - } - - return [{ value: group.value, label: group.label, items }]; + .slice(0, limit) + .map(({ item, match }) => { + if (match.ranges.length > 0) return { ...item, titleRanges: match.ranges }; + // Built only for the rows shown. + const matchHint = match.explain?.(); + return matchHint ? { ...item, matchHint } : item; + }); + return { value: group.value, label: group.label, items }; }); + + if (!searchesEverything) return groups; + const listed = new Set(groups.flatMap((group) => group.items.map((item) => item.value))); + // The items arrive cut to what the palette shows; see `nextShownOrder`. + const messageItems = (input.threadMessageItems ?? []).filter((item) => !listed.has(item.value)); + return messageItems.length === 0 + ? groups + : [...groups, { value: "threads-messages", label: "In messages", items: messageItems }]; } export function buildBrowseGroups(input: { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0d2b480e89c9..64b2b4edf7c9 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -219,7 +219,7 @@ import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; -import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; +import { settingsForThisPlatform, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; import { primaryServerKeybindingsAtom } from "../state/server"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; @@ -232,6 +232,7 @@ import { buildSidebarProjectPickerEntries } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; +import { threadRecentUse } from "@t3tools/shared/paletteSearch"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; @@ -895,9 +896,25 @@ function OpenCommandPaletteDialog(props: { ]; })(); - const { threadItems: allThreadItems, isSearchPending: isThreadSearchPending } = - useCommandPaletteThreadItems({ activeThreadId, threadSearchQuery }); - const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); + const { + threadItems: allThreadItems, + threadMessageItems, + now: searchNow, + isSearchPending: isThreadSearchPending, + } = useCommandPaletteThreadItems({ activeThread: activeThread ?? null, threadSearchQuery }); + // Recent means used recently: written in or opened, whichever is later. The + // open thread would always lead that list, so it is left out. + const activeThreadValue = activeThread + ? `thread:${activeThread.environmentId}:${activeThread.id}` + : null; + const recentThreadItems = allThreadItems + .filter((item) => item.value !== activeThreadValue) + .toSorted( + (left, right) => + (right.threadSearch ? threadRecentUse(right.threadSearch) : 0) - + (left.threadSearch ? threadRecentUse(left.threadSearch) : 0), + ) + .slice(0, RECENT_THREAD_LIMIT); // Stable so the item builders that capture it during render never see the // highlight ref it writes. @@ -1200,6 +1217,7 @@ function OpenCommandPaletteDialog(props: { New thread in {activeProjectTitle} ), + searchTitle: `New thread in ${activeProjectTitle}`, icon: , shortcutCommand: "chat.newLocal", run: async () => { @@ -1303,7 +1321,10 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:copy-thread-reference", - searchTerms: ["copy", "pull request", "pr link", "thread id", "reference"], + searchTerms: + activeThreadReferenceCopyTarget.kind === "pull-request" + ? ["copy", "pull request", "link", "reference"] + : ["copy", "thread id", "reference"], title: activeThreadReferenceCopyTarget.kind === "pull-request" ? "Copy PR link" : "Copy thread ID", description: activeThreadReferenceCopyTarget.value, @@ -1589,8 +1610,9 @@ function OpenCommandPaletteDialog(props: { } const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); - const settingsSearchItems: CommandPaletteActionItem[] = searchSettings( - deferredQuery, + // Every setting goes to the palette's own matcher, so settings get the same + // word starts and typo tolerance as threads. + const settingsSearchItems: CommandPaletteActionItem[] = settingsForThisPlatform( availableSettingsSearchItems, ).map((item) => ({ kind: "action", @@ -1625,6 +1647,8 @@ function OpenCommandPaletteDialog(props: { ? changeAppearanceItem.groups : (currentView?.groups ?? rootGroups); + const isLinkedThreadSearch = + linkedThreadSearch?.linkedThreads !== undefined && deferredQuery === linkedThreadSearch.query; const filteredGroups = filterCommandPaletteGroups({ activeGroups, query: deferredQuery, @@ -1632,7 +1656,7 @@ function OpenCommandPaletteDialog(props: { projectSearchItems: projectSearchItems, settingsSearchItems, threadSearchItems: - linkedThreadSearch?.linkedThreads && deferredQuery === linkedThreadSearch.query + isLinkedThreadSearch && linkedThreadSearch?.linkedThreads ? buildLinkedThreadActionItems({ ...linkedThreadSearch.linkedThreads, query: linkedThreadSearch.query, @@ -1645,6 +1669,8 @@ function OpenCommandPaletteDialog(props: { }, }) : allThreadItems, + threadMessageItems: isLinkedThreadSearch ? [] : threadMessageItems, + now: searchNow, }); const handleAddProjectForEnvironment = async (input: { @@ -2632,6 +2658,10 @@ function OpenCommandPaletteDialog(props: { > {`Open in ${fileManagerName}`} + ) : isThreadSearchPending && !isLinkedThreadSearch && !isBrowsing ? ( + // In the footer, so the list keeps its height while message results may + // still arrive below it. + Searching messages… ) : null; return ( diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index beb8892a4e57..a1d0c9d217d9 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -22,7 +22,11 @@ import { CommandShortcut, } from "./ui/command"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; -import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; +import { + HighlightedSearchText, + SearchMatchHintLine, + ThreadSearchMatchExcerpt, +} from "./ThreadSearchMatch"; interface CommandPaletteResultsProps { emptyStateMessage?: string; @@ -155,14 +159,44 @@ export function CommandPaletteTitleIcon(props: { children: ReactNode }) { return {props.children}; } -function CommandPaletteTitle({ title }: { title: ReactNode }) { +function CommandPaletteTitle({ + item, +}: { + item: Pick; +}) { + const { title } = item; return ( - {title} + {typeof title === "string" && item.titleRanges ? ( + + ) : ( + title + )} ); } +/** + * Project and environment for a thread row whose second line shows a message + * snippet or match hint instead, so similar rows can still be told apart. + */ +function ThreadRowContext({ + item, +}: { + item: CommandPaletteActionItem | CommandPaletteSubmenuItem; +}) { + if (!item.threadSearch || !(item.threadContentMatch || item.matchHint)) return null; + // A "Project: …" hint already names the project. + const context = [item.threadSearch.projectTitle, item.threadSearch.environmentLabel] + .filter((part) => part && part !== item.matchHint?.text) + .join(" · "); + return context ? ( + + {context} + + ) : null; +} + function DisabledCommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; }) { @@ -173,12 +207,14 @@ function DisabledCommandPaletteResultRow(props: { {props.item.titleLeadingContent} - + + {props.item.threadContentMatch ? ( - ) : null} - {props.item.description ? ( + ) : props.item.matchHint ? ( + + ) : props.item.description ? ( {props.item.description} @@ -187,7 +223,7 @@ function DisabledCommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - + )} {props.item.titleTrailingContent} @@ -223,12 +259,14 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} - + + {props.item.threadContentMatch ? ( - ) : null} - {props.item.description ? ( + ) : props.item.matchHint ? ( + + ) : props.item.description ? ( {props.item.description} @@ -237,7 +275,7 @@ function CommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - + )} {props.item.titleTrailingContent} diff --git a/apps/web/src/components/CommandPaletteThreadItems.tsx b/apps/web/src/components/CommandPaletteThreadItems.tsx index e2732f541908..41f62a1119ce 100644 --- a/apps/web/src/components/CommandPaletteThreadItems.tsx +++ b/apps/web/src/components/CommandPaletteThreadItems.tsx @@ -1,11 +1,23 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { findForMessageMatch, requestThreadFindQuery } from "./chat/threadFindActionBus"; +import { + claimThreadOpen, + nextShownOrder, + type EnvironmentThreadSearchMatch, + type ShownOrder, +} from "@t3tools/client-runtime/state/thread-search"; +import { + foldSearchQuery, + PALETTE_MESSAGE_LIMIT, + pickedQuery, + rankMessageMatches, +} from "@t3tools/shared/paletteSearch"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; import { MessageSquareIcon } from "lucide-react"; +import { useState } from "react"; -import { useClientSettings } from "../hooks/useSettings"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -19,7 +31,10 @@ import { } from "../state/environments"; import { useThreadSearch } from "../state/queries"; import { primaryServerProvidersAtom } from "../state/server"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams } from "../threadRoutes"; +import { formatRelativeTimeLabel } from "../timestampFormat"; import { buildThreadActionItems, ITEM_ICON_CLASS } from "./CommandPalette.logic"; import { useEnvironmentLookups, useProjectLookups } from "./CommandPaletteLookups"; import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle"; @@ -51,41 +66,44 @@ function useProviderEntryByEnvironmentAndInstanceId() { ); } -function useThreadSearchWithContentMatches(query: string) { +function useConnectedThreadSearch(query: string) { const { environments } = useEnvironments(); const environmentIds: ReadonlyArray = environments .filter((environment) => environment.connection.phase === "connected") .map((environment) => environment.environmentId); - const threadSearch = useThreadSearch(environmentIds, query); - const contentMatchByKey = new Map( - threadSearch.matches.flatMap((match) => - match.source === "user" || match.source === "assistant" - ? [[threadSearchMatchKey(match), match] as const] - : [], - ), - ); - return { threadSearch, contentMatchByKey }; + return useThreadSearch(environmentIds, query); } -/** Every thread as a palette item, with message matches for a non-empty `threadSearchQuery`. */ +/** + * Every visible thread as a palette item, plus, for a non-empty + * `threadSearchQuery`, the threads whose messages match, best first. + */ export function useCommandPaletteThreadItems(input: { - readonly activeThreadId: ThreadId | undefined; + readonly activeThread: { readonly environmentId: EnvironmentId; readonly id: ThreadId } | null; readonly threadSearchQuery: string; }) { - const { activeThreadId, threadSearchQuery } = input; + const { activeThread, threadSearchQuery } = input; const navigate = useNavigate(); const threads = useThreadShells(); - const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const { projectEnvironmentLocationById } = useEnvironmentLookups(); const providerEntryByEnvironmentAndInstanceId = useProviderEntryByEnvironmentAndInstanceId(); - const { threadSearch, contentMatchByKey } = useThreadSearchWithContentMatches(threadSearchQuery); - const { projectByKey, projectTitleById } = useProjectLookups(); + const messageSearch = useConnectedThreadSearch(threadSearchQuery); + const { projectByKey } = useProjectLookups(); + const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); + // Ranking is relative to when the palette opened, so rows do not reorder as time passes. + const [now] = useState(() => Date.now()); + const { environments } = useEnvironments(); const threadItems = buildThreadActionItems({ threads, - ...(activeThreadId ? { activeThreadId } : {}), - projectTitleById, - sortOrder: sidebarThreadSortOrder, + ...(environments.length > 1 + ? { + environmentLabelOf: (thread: (typeof threads)[number]) => + projectEnvironmentLocationById.get(thread.environmentId)?.label ?? null, + } + : {}), + ...(activeThread ? { activeThread } : {}), + projectByKey, icon: , renderLeadingContent: (thread) => , renderTrailingContent: (thread) => ( @@ -113,7 +131,9 @@ export function useCommandPaletteThreadItems(input: { } branch={thread.branch} worktreePath={thread.worktreePath} - isCurrent={thread.id === activeThreadId} + isCurrent={ + thread.id === activeThread?.id && thread.environmentId === activeThread.environmentId + } driverKind={providerEntry?.driverKind ?? null} providerDisplayName={ thread.runtime?.providerName ?? providerEntry?.displayName ?? modelInstanceId @@ -123,22 +143,30 @@ export function useCommandPaletteThreadItems(input: { /> ); }, - getContentMatch: (thread) => { - const match = contentMatchByKey.get( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - ); - return match && (match.source === "user" || match.source === "assistant") - ? { - source: match.source, - snippet: match.snippet, - query: threadSearch.query, - } - : undefined; - }, runThread: async (thread) => { + // A pick sends its open with the query, always, so the server records + // it; the thread view then skips its own open. The open thread is no pick. + const isOpen = + activeThread?.environmentId === thread.environmentId && activeThread.id === thread.id; + if (threadSearchQuery.trim().length > 0 && !threadSearchQuery.startsWith(">") && !isOpen) { + claimThreadOpen(thread.environmentId, thread.id, Date.now()); + } + if ( + threadSearchQuery.trim().length > 0 && + !threadSearchQuery.startsWith(">") && + thread.lastVisitedAt !== undefined && + !isOpen + ) { + void visitThread({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + visitedAt: thread.updatedAt, + opened: true, + searchQuery: pickedQuery(threadSearchQuery), + }, + }); + } await navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), @@ -146,5 +174,62 @@ export function useCommandPaletteThreadItems(input: { }, }); - return { threadItems, isSearchPending: threadSearch.isPending }; + const itemByValue = new Map(threadItems.map((item) => [item.value, item])); + const itemForMatch = (match: EnvironmentThreadSearchMatch) => + itemByValue.get(`thread:${match.environmentId}:${match.threadId}`); + const rankedMessageItems = rankMessageMatches( + messageSearch.matches, + (match) => itemForMatch(match)?.threadSearch, + foldSearchQuery(threadSearchQuery), + now, + { withoutTitleMatches: true }, + ).flatMap((match) => { + const item = itemForMatch(match); + if (!item) return []; + // The snippet replaces the project line, so a message row is as tall as a title row. + const { description: _description, ...rest } = item; + return [ + { + ...rest, + // When the matching message was written, not when the thread last moved. + ...(match.messageCreatedAt + ? { timestamp: formatRelativeTimeLabel(match.messageCreatedAt) } + : {}), + threadContentMatch: { + source: match.source, + snippet: match.snippet, + query: messageSearch.query, + }, + // Opens the thread with find on the word at the matched message, so it is in view. + run: async () => { + await item.run(); + requestThreadFindQuery( + scopedThreadKey(scopeThreadRef(match.environmentId, match.threadId)), + findForMessageMatch(match, messageSearch.query), + ); + }, + }, + ]; + }); + + // Rows already shown keep their places when a slower environment answers. + const [shownOrder, setShownOrder] = useState(null); + const order = nextShownOrder( + shownOrder, + messageSearch.query, + rankedMessageItems.map((item) => item.value), + PALETTE_MESSAGE_LIMIT, + ); + if (order !== shownOrder) setShownOrder(order); + const messageItemByValue = new Map(rankedMessageItems.map((item) => [item.value, item])); + const threadMessageItems = order.keys + .slice(0, order.shown) + .flatMap((value) => messageItemByValue.get(value) ?? []); + + return { + threadItems, + threadMessageItems, + now, + isSearchPending: messageSearch.isPending, + }; } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0485e93ff74c..5bcda0795cc5 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -323,17 +323,23 @@ describe("resolveSidebarThreadStatus", () => { }); describe("searchSidebarThreads", () => { - const searchThread = (id: string, title: string, project: string) => ({ + const searchThread = (id: string, title: string, latestUserMessageAt: string) => ({ environmentId: localEnvironmentId, id: ThreadId.make(id), title, - project, + branch: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: latestUserMessageAt, + latestUserMessageAt, + opens: null, }); const threads = [ - searchThread("thread-1", "Fix workspace search", "Alpha"), - searchThread("thread-2", "Review providers", "Workspace"), - searchThread("thread-3", "WORKTREE cleanup", "Beta"), + searchThread("thread-1", "Fix workspace search", "2026-10-20T00:00:00.000Z"), + searchThread("thread-2", "Review providers", "2026-10-22T00:00:00.000Z"), + searchThread("thread-3", "WORKTREE cleanup", "2026-09-01T00:00:00.000Z"), + searchThread("thread-4", "Homework notes", "2026-10-22T00:00:00.000Z"), ]; + const now = Date.parse("2026-10-23T00:00:00.000Z"); const contentKeys = (...ids: ReadonlyArray) => new Set( ids.map((id) => @@ -341,11 +347,13 @@ describe("searchSidebarThreads", () => { ), ); - it("lists a thread matching both title and content once", () => { - expect(searchSidebarThreads(threads, "work", contentKeys("thread-1"))).toEqual([ - threads[0], - threads[2], - ]); + it("ranks word matches by recent use, then lists message matches once", () => { + expect(searchSidebarThreads(threads, "work", contentKeys("thread-1", "thread-2"), now)).toEqual( + { + titleMatches: [threads[0], threads[2], threads[3]], + contentMatches: [threads[1]], + }, + ); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e24439a17f09..97046c71a11c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -7,7 +7,12 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { + threadSearchMatchKey, + threadSearchSubject, + type ThreadSearchShell, +} from "@t3tools/client-runtime/state/thread-search"; +import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; @@ -975,37 +980,43 @@ export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3t const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet = new Set(); +/** + * Sidebar search results: title, branch, and pull request matches ranked as + * the command palette ranks them, then threads listed only because their + * messages match, in `contentMatchKeys` order. + */ export function searchSidebarThreads< - T extends { + T extends ThreadSearchShell & { readonly environmentId: EnvironmentId; readonly id: ThreadId; - readonly title: string; - } & Parameters[0], + }, >( threads: readonly T[], query: string, contentMatchKeys: ReadonlySet = EMPTY_CONTENT_MATCH_KEYS, -): T[] { - const normalizedQuery = query.trim().toLowerCase(); - if (normalizedQuery.length === 0) return []; - const titleMatches: T[] = []; - const contentMatches: T[] = []; - for (const thread of threads) { - const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => - term.toLowerCase().includes(normalizedQuery), - ); - if (matchesTitle) { - titleMatches.push(thread); - } else if ( - contentMatchKeys.size > 0 && - contentMatchKeys.has( - threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), - ) - ) { - contentMatches.push(thread); - } - } - return [...titleMatches, ...contentMatches]; + now = Date.now(), +): { readonly titleMatches: T[]; readonly contentMatches: T[] } { + const normalizedQuery = foldSearchQuery(query); + if (normalizedQuery.length === 0) return { titleMatches: [], contentMatches: [] }; + const titleMatches = rankSearchItems(threads, (thread, options) => + scoreThreadSearch(normalizedQuery, threadSearchSubject(thread, null), now, { + ...options, + picks: false, + }), + ).map(({ item }) => item); + const listed = new Set(titleMatches); + // In the order the server ranked their messages, as the palette lists them. + const threadByKey = new Map( + threads.map((thread) => [ + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + thread, + ]), + ); + const contentMatches = [...contentMatchKeys].flatMap((key) => { + const thread = threadByKey.get(key); + return thread && !listed.has(thread) ? [thread] : []; + }); + return { titleMatches, contentMatches }; } export function pullRequestMatchesSearchQuery( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7f03b6c5c785..827f62ee6119 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -30,7 +30,14 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox"; import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { + nextShownOrder, + threadSearchMatchKey, + threadSearchSubject, + type ShownOrder, +} from "@t3tools/client-runtime/state/thread-search"; +import { foldSearchQuery, rankMessageMatches } from "@t3tools/shared/paletteSearch"; +import { findForMessageMatch, requestThreadFindQuery } from "./chat/threadFindActionBus"; import { threadRuntimeCanArchive, type EnvironmentThreadShell, @@ -871,7 +878,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const [optimisticDrop, setOptimisticDrop] = useState(null); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); - const [searchHighlight, setSearchHighlight] = useState({ orderKey: "", index: 0 }); + const [searchHighlightKey, setSearchHighlightKey] = useState(null); const searchEnvironmentIds = useConnectedEnvironmentIds(); const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery); const handleNewThreadRef = useRef(newThreadContext.handleNewThread); @@ -1104,36 +1111,76 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve isSearchingThreads, ]); + // Ranked across environments as the palette ranks them, so one + // environment's list does not sit above another's better matches; rows + // already shown keep their places while slower environments answer. + const threadByMatchKey = + threadSearch.matches.length === 0 + ? null + : new Map( + searchableThreads.map((thread) => [ + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + thread, + ]), + ); + const rankedServerMatches = + threadByMatchKey === null + ? [] + : rankMessageMatches( + threadSearch.matches, + (match) => { + const thread = threadByMatchKey.get(threadSearchMatchKey(match)); + return thread ? threadSearchSubject(thread, null) : undefined; + }, + foldSearchQuery(threadSearchQuery), + Date.now(), + // A list filter, as on mobile: a pick would move rows unexplained. + { picks: false }, + ); + const serverMatchByKey = new Map( + rankedServerMatches.map((match) => [threadSearchMatchKey(match), match] as const), + ); + const [shownMessageOrder, setShownMessageOrder] = useState(null); + const messageOrder = nextShownOrder( + shownMessageOrder, + threadSearch.query, + [...serverMatchByKey.keys()], + Number.POSITIVE_INFINITY, + ); + if (messageOrder !== shownMessageOrder) setShownMessageOrder(messageOrder); + const { titleMatches: threadTitleResults, contentMatches: threadContentResults } = + searchSidebarThreads(searchableThreads, threadSearchQuery, new Set(messageOrder.keys)); + const threadSearchResults = [...threadTitleResults, ...threadContentResults]; + // Only rows listed for their messages show one, and open find on it. const threadSearchMatchByKey = new Map( - threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const), + threadContentResults.flatMap((thread) => { + const key = threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const match = serverMatchByKey.get(key); + return match ? [[key, match] as const] : []; + }), ); - const threadSearchResults = searchSidebarThreads( - searchableThreads, - threadSearchQuery, - new Set(threadSearchMatchByKey.keys()), + const threadSearchResultKeys = threadSearchResults.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); - const threadSearchResultOrderKey = threadSearchResults - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) - .join("\0"); - // The highlight belongs to one result list; a new list starts at the top. - const activeSearchResultIndex = - searchHighlight.orderKey === threadSearchResultOrderKey ? searchHighlight.index : 0; + // The highlight follows its thread, so rows that arrive late do not move + // it under Enter; a new query starts at the top. + const activeSearchResultIndex = Math.max( + 0, + searchHighlightKey === null ? 0 : threadSearchResultKeys.indexOf(searchHighlightKey), + ); const setActiveSearchResultIndex = (index: number | ((index: number) => number)) => - setSearchHighlight((current) => { - const currentIndex = current.orderKey === threadSearchResultOrderKey ? current.index : 0; - return { - orderKey: threadSearchResultOrderKey, - index: typeof index === "function" ? index(currentIndex) : index, - }; - }); - const activeSearchResult = isSearchingThreads - ? threadSearchResults[activeSearchResultIndex] - : undefined; - const activeSearchResultKey = - activeSearchResult === undefined - ? null - : scopedThreadKey(scopeThreadRef(activeSearchResult.environmentId, activeSearchResult.id)); + setSearchHighlightKey( + threadSearchResultKeys[ + typeof index === "function" ? index(activeSearchResultIndex) : index + ] ?? null, + ); + const activeSearchResultKey = isSearchingThreads + ? (threadSearchResultKeys[activeSearchResultIndex] ?? null) + : null; useEffect(() => { if (activeSearchResultKey === null) return; document @@ -1208,11 +1255,18 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const clearThreadSearch = () => { setThreadSearchQuery(""); - setActiveSearchResultIndex(0); + setSearchHighlightKey(null); }; const selectThreadSearchResult = (thread: EnvironmentThreadShell) => { + const ref = scopeThreadRef(thread.environmentId, thread.id); + // A row listed for a message match opens find on it, as the palette does. + const match = threadSearchMatchByKey.get( + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + ); + const query = threadSearch.query; clearThreadSearch(); - navigateToThread(scopeThreadRef(thread.environmentId, thread.id)); + navigateToThread(ref); + if (match) requestThreadFindQuery(scopedThreadKey(ref), findForMessageMatch(match, query)); }; const handleThreadSearchKeyDown = (event: ReactKeyboardEvent) => { if (event.nativeEvent.isComposing || event.keyCode === 229) return; @@ -2771,7 +2825,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve searchQuery={threadSearchQuery} onSearchQueryChange={(value) => { setThreadSearchQuery(value); - setActiveSearchResultIndex(0); + setSearchHighlightKey(null); }} onSearchKeyDown={handleThreadSearchKeyDown} isSearching={isSearchingThreads} @@ -2834,6 +2888,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) ?? null } searchQuery={threadSearchQuery} + searchMatchQuery={threadSearch.query} onHighlight={() => setActiveSearchResultIndex(index)} onSelect={() => selectThreadSearchResult(thread)} onFileDropThreads={handleThreadFileDrop} diff --git a/apps/web/src/components/ThreadSearchMatch.tsx b/apps/web/src/components/ThreadSearchMatch.tsx index a79ab8543987..1af7f2810903 100644 --- a/apps/web/src/components/ThreadSearchMatch.tsx +++ b/apps/web/src/components/ThreadSearchMatch.tsx @@ -1,42 +1,14 @@ -function foldAsciiCase(value: string): string { - return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); -} - -function HighlightedSearchText(props: { text: string; query: string }) { - const query = props.query.trim(); - if (query.length === 0) return props.text; - - const normalizedText = foldAsciiCase(props.text); - const normalizedQuery = foldAsciiCase(query); - const parts: Array<{ - readonly text: string; - readonly highlighted: boolean; - readonly start: number; - }> = []; - let cursor = 0; +import { + findSearchTokenRanges, + splitSearchText, + type SearchMatchHint, + type SearchRange, +} from "@t3tools/shared/paletteSearch"; - while (cursor < props.text.length) { - const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); - if (matchIndex === -1) { - parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); - break; - } - if (matchIndex > cursor) { - parts.push({ - text: props.text.slice(cursor, matchIndex), - highlighted: false, - start: cursor, - }); - } - parts.push({ - text: props.text.slice(matchIndex, matchIndex + query.length), - highlighted: true, - start: matchIndex, - }); - cursor = matchIndex + query.length; - } - - return parts.map((part) => +/** `text` with each range in semibold foreground. */ +export function HighlightedSearchText(props: { text: string; ranges: ReadonlyArray }) { + if (props.ranges.length === 0) return props.text; + return splitSearchText(props.text, props.ranges).map((part) => part.highlighted ? ( {part.text} @@ -47,6 +19,16 @@ function HighlightedSearchText(props: { text: string; query: string }) { ); } +/** Why a row matched when its title does not show it: the field and its match. */ +export function SearchMatchHintLine(props: { hint: SearchMatchHint }) { + return ( + + {props.hint.label}:{" "} + + + ); +} + export function ThreadSearchMatchExcerpt(props: { match: { readonly source: "user" | "assistant"; @@ -60,7 +42,10 @@ export function ThreadSearchMatchExcerpt(props: { {isUser ? "You:" : "Agent:"} {" "} - + ); } diff --git a/apps/web/src/components/chat/ThreadVisitWatermark.tsx b/apps/web/src/components/chat/ThreadVisitWatermark.tsx index 9d36f6f73ca3..b2066602c61e 100644 --- a/apps/web/src/components/chat/ThreadVisitWatermark.tsx +++ b/apps/web/src/components/chat/ThreadVisitWatermark.tsx @@ -1,6 +1,7 @@ import { type ScopedThreadRef } from "@t3tools/contracts"; import { resolveThreadLastVisitedAt } from "../Sidebar.logic"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { claimThreadOpen } from "@t3tools/client-runtime/state/thread-search"; import { useEffect, useRef } from "react"; import { useUiStateStore } from "../../uiStateStore"; import { threadEnvironment } from "../../state/threads"; @@ -25,6 +26,36 @@ export function ThreadVisitWatermark(props: { readonly threadRef: ScopedThreadRe const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const lastDispatchedVisitRef = useRef(null); const lastVisitDispatchAtRef = useRef(0); + const recordedOpenRef = useRef(null); + + // Opening a thread is one visit that also counts toward search ranking. A + // palette pick already sent it with the query. It runs before the watermark + // effect, which then sees this watermark as sent. + useEffect(() => { + if (!serverThread?.id) return; + if (recordedOpenRef.current === threadKey) return; + recordedOpenRef.current = threadKey; + // Recorded here even when the server cannot take opens, so recents follow it. + const claimed = claimThreadOpen(serverThread.environmentId, serverThread.id, Date.now()); + if (!claimed || serverThread.lastVisitedAt === undefined) return; + lastDispatchedVisitRef.current = `${threadKey}:${serverThread.updatedAt}`; + lastVisitDispatchAtRef.current = Date.now(); + void visitThreadMutation({ + environmentId: serverThread.environmentId, + input: { + threadId: serverThread.id, + visitedAt: serverThread.updatedAt, + opened: true, + }, + }); + }, [ + threadKey, + serverThread?.environmentId, + serverThread?.id, + serverThread?.lastVisitedAt, + serverThread?.updatedAt, + visitThreadMutation, + ]); useEffect(() => { if (!serverThread?.id) return; diff --git a/apps/web/src/components/chat/threadFindActionBus.test.ts b/apps/web/src/components/chat/threadFindActionBus.test.ts new file mode 100644 index 000000000000..85079deb0a71 --- /dev/null +++ b/apps/web/src/components/chat/threadFindActionBus.test.ts @@ -0,0 +1,22 @@ +import { MessageId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { findForMessageMatch } from "./threadFindActionBus"; + +describe("findForMessageMatch", () => { + it("opens find on the longest word as the snippet writes it, at the matched message", () => { + expect( + findForMessageMatch( + { snippet: "The cafeteria, then the Café", messageId: MessageId.make("message-1") }, + "cafe", + ), + ).toEqual({ query: "Café", start: { entryId: "message-1", occurrence: 0 } }); + expect(findForMessageMatch({ snippet: "fix the sidebar lag" }, "fix sidebar")).toEqual({ + query: "sidebar", + }); + // The whole query, when the snippet holds it as typed. + expect(findForMessageMatch({ snippet: "then Vp run dev again" }, "vp run dev")).toEqual({ + query: "Vp run dev", + }); + }); +}); diff --git a/apps/web/src/components/chat/threadFindActionBus.ts b/apps/web/src/components/chat/threadFindActionBus.ts index 375610fbc077..9230e5fbd69f 100644 --- a/apps/web/src/components/chat/threadFindActionBus.ts +++ b/apps/web/src/components/chat/threadFindActionBus.ts @@ -1,3 +1,11 @@ +import type { OrchestrationThreadSearchMatch } from "@t3tools/contracts"; +import { + findSearchTokenRanges, + messagePhrase, + messageSearchTokens, +} from "@t3tools/shared/paletteSearch"; +import type { ThreadFindStart } from "./threadFind"; + const THREAD_FIND_OPEN_EVENT = "t3:thread-find-open"; export function requestThreadFindOpen(): void { @@ -8,3 +16,65 @@ export function subscribeThreadFindOpen(listener: () => void): () => void { window.addEventListener(THREAD_FIND_OPEN_EVENT, listener); return () => window.removeEventListener(THREAD_FIND_OPEN_EVENT, listener); } + +const THREAD_FIND_QUERY_EVENT = "t3:thread-find-query"; +interface PendingFind { + readonly query: string; + /** Where find starts, such as the message a palette result came from. */ + readonly start?: ThreadFindStart; +} +// A find a palette message result asked for, until that thread's view takes +// it. One the view never took (the navigation failed) lets go after a while, +// so a later visit does not open an old find. +const PENDING_FIND_MS = 30_000; +let pendingFind: (PendingFind & { readonly threadKey: string; readonly at: number }) | null = null; + +/** Opens find on `query` in the thread's view, now or once it mounts. */ +export function requestThreadFindQuery(threadKey: string, find: PendingFind): void { + pendingFind = { threadKey, at: Date.now(), ...find }; + window.dispatchEvent(new Event(THREAD_FIND_QUERY_EVENT)); +} + +/** The find waiting for this thread's view; answers once. */ +export function takeThreadFindQuery(threadKey: string): PendingFind | null { + if (pendingFind?.threadKey !== threadKey) return null; + const { at, threadKey: _threadKey, ...find } = pendingFind; + pendingFind = null; + return Date.now() - at < PENDING_FIND_MS ? find : null; +} + +/** + * The find a message search result opens, at the matched message: the whole + * query when the snippet holds it as typed ("vp run dev"), else its longest + * word, as the most telling ("sidebar" in "fix sidebar"), written as the + * snippet writes it ("Café" for "cafe", since find matches accents). + */ +export function findForMessageMatch( + match: Pick, + query: string, +): PendingFind { + const start = match.messageId ? { start: { entryId: match.messageId, occurrence: 0 } } : {}; + // Apostrophes are straight in the query; straightening keeps offsets. + const together = messagePhrase(query)?.exec(match.snippet.replace(/[\u2018\u2019]/g, "'")); + if (together) { + return { + query: match.snippet.slice(together.index, together.index + together[0].length), + ...start, + }; + } + const word = + messageSearchTokens(query).long.reduce( + (longest, token) => (token.length > longest.length ? token : longest), + "", + ) || query; + const ranges = findSearchTokenRanges(match.snippet, word); + // A whole word first, as search ranked it: "Café", not "Cafe" in "Cafeteria". + const shown = + ranges.find((range) => !/[\p{L}\p{N}]/u.test(match.snippet[range.end] ?? "")) ?? ranges[0]; + return { query: shown ? match.snippet.slice(shown.start, shown.end) : word, ...start }; +} + +export function subscribeThreadFindQuery(listener: () => void): () => void { + window.addEventListener(THREAD_FIND_QUERY_EVENT, listener); + return () => window.removeEventListener(THREAD_FIND_QUERY_EVENT, listener); +} diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index cfbcf73c60e2..e2c9a3490049 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -1,4 +1,5 @@ import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; +import { useState } from "react"; import type { EnvironmentId, ProjectId, @@ -74,6 +75,8 @@ export function useComposerMenuItems({ readonly selectedProviderSlashCommands: ServerProvider["slashCommands"]; readonly showSkillsInSlashMenu: boolean; }) { + // Thread mentions rank by recency as of when the composer mounted. + const [now] = useState(() => Date.now()); const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const pullRequestTriggerQuery = @@ -162,6 +165,7 @@ export function useComposerMenuItems({ environmentId, excludeThreadId: activeThreadId, query: composerTrigger.query, + now, }), ...workspaceEntries.entries.map((entry) => ({ id: `path:${entry.kind}:${entry.path}`, diff --git a/apps/web/src/components/chat/useThreadFind.ts b/apps/web/src/components/chat/useThreadFind.ts index 58b1f710dfb3..7dd74a4b52d5 100644 --- a/apps/web/src/components/chat/useThreadFind.ts +++ b/apps/web/src/components/chat/useThreadFind.ts @@ -12,7 +12,11 @@ import { type ThreadFindStart, stepThreadFindIndex, } from "./threadFind"; -import { subscribeThreadFindOpen } from "./threadFindActionBus"; +import { + subscribeThreadFindOpen, + subscribeThreadFindQuery, + takeThreadFindQuery, +} from "./threadFindActionBus"; import { toastManager } from "../ui/toast"; const EMPTY_SKILLS: readonly InlineSkill[] = []; @@ -94,6 +98,31 @@ export function useThreadFind({ }); const close = useStableCallback(() => setState(CLOSED_FIND)); useEffect(() => subscribeThreadFindOpen(open), [open]); + const openOnQuery = useStableCallback((query: string, start?: ThreadFindStart) => { + if (threadKey === null) return; + setState((previous) => ({ + ...(previous.threadKey === threadKey ? previous : CLOSED_FIND), + threadKey, + query, + offset: 0, + start, + localSelection: null, + focusRequestId: previous.focusRequestId + 1, + navigationId: previous.navigationId + 1, + requestId: previous.navigationId + 1, + })); + }); + // A palette message result opens find on its query, at its message, once + // this thread shows. + useEffect(() => { + if (threadKey === null || !enabled) return; + const take = () => { + const find = takeThreadFindQuery(threadKey); + if (find !== null) openOnQuery(find.query, find.start); + }; + take(); + return subscribeThreadFindQuery(take); + }, [enabled, openOnQuery, threadKey]); const remote = useServerResults( isOpen ? thread : null, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f509af43a8db..eac65e29742e 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1143,6 +1143,20 @@ export function filterAvailableSettingsSearchItems( ); } +/** The settings that exist on this platform and app channel. */ +export function settingsForThisPlatform( + items: ReadonlyArray, +): ReadonlyArray { + const platform = typeof navigator === "undefined" ? "" : navigator.platform; + return items.filter( + (item) => + (isElectron || item.desktopOnly !== true) && + (!item.releaseChannelOnly || isElectron || HOSTED_APP_CHANNEL !== null) && + (!item.macOnly || isMacPlatform(platform)) && + (!item.windowsOnly || isWindowsPlatform(platform)), + ); +} + export function searchSettings( query: string, items: ReadonlyArray = SETTINGS_SEARCH_ITEMS, @@ -1150,15 +1164,9 @@ export function searchSettings( const normalizedQuery = normalizeSearchText(query); if (normalizedQuery.length === 0) return []; const queryTokens = normalizedQuery.split(" "); - const platform = typeof navigator === "undefined" ? "" : navigator.platform; - return items + return settingsForThisPlatform(items) .flatMap((item, index) => { - if (!isElectron && item.desktopOnly === true) return []; - if (item.releaseChannelOnly && !isElectron && HOSTED_APP_CHANNEL === null) return []; - if (item.macOnly && !isMacPlatform(platform)) return []; - if (item.windowsOnly && !isWindowsPlatform(platform)) return []; - const title = normalizeSearchText(item.title); const fields = [ title, diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index ac7ae29afefc..cfab5ca8b0c1 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1517,6 +1517,8 @@ export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props resultId: string; searchMatch: EnvironmentThreadSearchMatch | null; searchQuery: string; + /** The query `searchMatch` was found for, which lags `searchQuery` while a search runs. */ + searchMatchQuery?: string; onHighlight: () => void; onSelect: () => void; onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; @@ -1655,7 +1657,7 @@ export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props match={{ source: props.searchMatch.source, snippet: props.searchMatch.snippet, - query: props.searchQuery, + query: props.searchMatchQuery ?? props.searchQuery, }} /> ) : null} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 5956a814c36f..fcf12d209a0e 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -6,8 +6,12 @@ import { import { createThreadSearchResultsAtomFamily, makeThreadSearchKey, + visibleThreadSearch, type EnvironmentThreadSearchMatch, + type SettledThreadSearch, } from "@t3tools/client-runtime/state/thread-search"; +import { THREAD_SEARCH_QUERY_MAX_LENGTH } from "@t3tools/contracts"; +import { messageSearchTokens } from "@t3tools/shared/paletteSearch"; import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, @@ -36,7 +40,7 @@ const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; const PROJECT_CONTENT_SEARCH_LIMIT = 500; -const THREAD_SEARCH_DEBOUNCE_MS = 200; +const THREAD_SEARCH_DEBOUNCE_MS = 120; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; const EMPTY_PROJECT_ENTRIES: ReadonlyArray = []; @@ -80,20 +84,35 @@ export function useThreadSearch( readonly query: string; readonly isPending: boolean; } { - const normalizedQuery = query.trim(); + // A pasted wall of text searches by its start rather than failing the request. + const normalizedQuery = query.trim().slice(0, THREAD_SEARCH_QUERY_MAX_LENGTH).trim(); const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS); - const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2; + // The server needs a word of three or more letters; shorter queries would always come back empty. + const canSearch = + environmentIds.length > 0 && messageSearchTokens(normalizedQuery).long.length > 0; const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null; const searchKey = settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery); const result = useAtomValue( searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), ); - const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; + const isPending = canSearch && (normalizedQuery !== debouncedQuery || result.isLoading); + // What fast environments returned shows without waiting for a slow one; + // callers keep shown rows in place with `nextShownOrder`. + const ready = + settledQuery !== null && (!result.isLoading || result.matches.length > 0) + ? { query: settledQuery, matches: result.matches } + : null; + // Kept from earlier renders: React's pattern for information from previous renders. + const [previous, setPrevious] = useState(null); + if (ready !== null && previous?.matches !== ready.matches) setPrevious(ready); + const visible = canSearch + ? visibleThreadSearch(normalizedQuery, ready, previous) + : { query: "", matches: EMPTY_THREAD_SEARCH_MATCHES }; return { - matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, - query: settledQuery ?? "", - isPending: canSearch && (isDebouncing || result.isLoading), + matches: visible.matches, + query: visible.query, + isPending, }; } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c2c02ca5be61..2103e4c565b5 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -187,8 +187,15 @@ The linked pull request participates in automatic settlement. ## Find and reference work On web and desktop, open the command palette with `Cmd/Ctrl+K` to search threads -across connected environments. Message search starts after two characters and -includes your messages and final agent responses. +across connected environments by title, branch, pull request, or project. +Threads you open often or recently rank higher, and for three weeks the thread +you last picked for a query ranks higher when you type that query again. +Threads whose messages contain all your words appear below, under **In +messages**, and open at the matching message. Message search needs at least +one word of three or more letters, or two for Chinese, Japanese, and Korean. +On mobile, the thread list search also lists message matches and opens them at +the matching message; on an iPad with a keyboard, `Cmd+K` opens the same +palette. Use **Settings → Keybindings** to find or customize shortcuts for searching files and copying a thread reference. A copied reference uses the thread's pull request diff --git a/packages/client-runtime/src/composerThreadItems.test.ts b/packages/client-runtime/src/composerThreadItems.test.ts new file mode 100644 index 000000000000..2e5d8d7b7c3c --- /dev/null +++ b/packages/client-runtime/src/composerThreadItems.test.ts @@ -0,0 +1,50 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { expect, it } from "vite-plus/test"; + +import { matchComposerThreadItems, type ComposerThreadCandidate } from "./composerThreadItems.ts"; + +const environmentId = EnvironmentId.make("env-composer"); + +function shell( + id: string, + title: string, + overrides: Partial = {}, +): ComposerThreadCandidate { + return { + environmentId, + id: ThreadId.make(id), + title, + branch: null, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + latestUserMessageAt: null, + opens: null, + archivedAt: null, + lineage: { rootThreadId: ThreadId.make(id), parentThreadId: null, relationshipToParent: null }, + ...overrides, + }; +} + +it("ranks thread mentions like the palette and leaves out subagent threads", () => { + const items = matchComposerThreadItems({ + shells: [ + shell("newer-mid-word", "Remodel the settings page", { + updatedAt: "2026-10-20T00:00:00.000Z", + }), + shell("word", "Fix model picker"), + shell("subagent", "Model review", { + lineage: { + rootThreadId: ThreadId.make("word"), + parentThreadId: ThreadId.make("word"), + relationshipToParent: "subagent", + }, + }), + shell("other-environment", "Model docs", { environmentId: EnvironmentId.make("env-other") }), + ], + environmentId, + excludeThreadId: null, + query: "model", + now: Date.parse("2026-10-23T00:00:00.000Z"), + }); + expect(items.map((item) => item.thread.threadId)).toEqual(["word", "newer-mid-word"]); +}); diff --git a/packages/client-runtime/src/composerThreadItems.ts b/packages/client-runtime/src/composerThreadItems.ts index f94de963afe5..37657af944fb 100644 --- a/packages/client-runtime/src/composerThreadItems.ts +++ b/packages/client-runtime/src/composerThreadItems.ts @@ -1,14 +1,13 @@ import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; + +import type { EnvironmentThreadShell } from "./state/models.ts"; +import { threadSearchSubject, type ThreadSearchShell } from "./state/threadSearch.ts"; const COMPOSER_THREAD_RESULT_LIMIT = 5; -export interface ComposerThreadCandidate { - readonly environmentId: EnvironmentId; - readonly id: ThreadId; - readonly title: string; - readonly updatedAt: string; - readonly archivedAt: string | null; -} +export type ComposerThreadCandidate = ThreadSearchShell & + Pick; export interface ComposerThreadItem { readonly id: string; @@ -19,28 +18,35 @@ export interface ComposerThreadItem { } /** - * The agent can only read threads on its own server, so candidates stay within the - * composer's environment. + * Threads to reference from the composer, matched and ranked like the command + * palette, without subagent threads. The agent can only read threads on its + * own server, so candidates stay within the composer's environment. */ export function matchComposerThreadItems(input: { shells: ReadonlyArray; environmentId: EnvironmentId; excludeThreadId: ThreadId | null; query: string; + /** Recency is measured against this time. */ + now: number; }): ComposerThreadItem[] { - const query = input.query.trim().toLowerCase(); + const query = foldSearchQuery(input.query); if (query.length === 0) return []; - return input.shells - .filter( - (shell) => - shell.environmentId === input.environmentId && - shell.id !== input.excludeThreadId && - shell.archivedAt === null && - shell.title.toLowerCase().includes(query), - ) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + const candidates = input.shells.filter( + (shell) => + shell.environmentId === input.environmentId && + shell.id !== input.excludeThreadId && + shell.archivedAt === null && + shell.lineage.relationshipToParent !== "subagent", + ); + return rankSearchItems(candidates, (shell, options) => + scoreThreadSearch(query, threadSearchSubject(shell, null), input.now, { + ...options, + picks: false, + }), + ) .slice(0, COMPOSER_THREAD_RESULT_LIMIT) - .map((shell) => ({ + .map(({ item: shell }) => ({ id: `thread:${shell.environmentId}:${shell.id}`, type: "thread", thread: { environmentId: shell.environmentId, threadId: shell.id }, diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 8663b433d2c2..d5cd4247b5a6 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -114,6 +114,9 @@ export interface UnsnoozeThreadInput extends ThreadCommandInput { export interface VisitThreadInput extends ThreadCommandInput { readonly visitedAt: string; + /** Counts toward search frecency; `searchQuery` is the palette query that picked it. */ + readonly opened?: boolean; + readonly searchQuery?: string; } export type MarkThreadUnreadInput = ThreadCommandInput; @@ -542,6 +545,8 @@ export const visitThread = Effect.fn("EnvironmentCommands.visitThread")(function commandId: yield* allocateCommandId(input), threadId: input.threadId, visitedAt: input.visitedAt, + ...(input.opened ? { opened: true } : {}), + ...(input.searchQuery ? { searchQuery: input.searchQuery } : {}), }); }); diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 537f1455a412..ad011530b5d6 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -186,6 +186,8 @@ export interface EnvironmentThreadShell { * local visited state they keep. */ readonly lastVisitedAt?: string | null; + /** Server-tracked open history for search ranking. */ + readonly opens?: import("@t3tools/contracts").OrchestrationV2ThreadOpens | null; readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null; readonly deletedAt: string | null; readonly source: OrchestrationV2ThreadShell; @@ -332,6 +334,7 @@ export function presentThreadShell( ...(thread.lastVisitedAt === undefined ? {} : { lastVisitedAt: nullableIso(thread.lastVisitedAt) }), + opens: thread.opens ?? null, titleRegeneration: thread.titleRegeneration == null ? null diff --git a/packages/client-runtime/src/state/threadSearch.test.ts b/packages/client-runtime/src/state/threadSearch.test.ts index d40abf38997a..26c3d9cc01c7 100644 --- a/packages/client-runtime/src/state/threadSearch.test.ts +++ b/packages/client-runtime/src/state/threadSearch.test.ts @@ -9,9 +9,13 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { expect, it } from "vite-plus/test"; import { + claimThreadOpen, + nextShownOrder, createThreadSearchResultsAtomFamily, makeThreadSearchKey, threadSearchMatchKey, + threadSearchSubject, + visibleThreadSearch, } from "./threadSearch.ts"; const envA = EnvironmentId.make("env-a"); @@ -100,3 +104,96 @@ it("merges successful environments and silently ignores failures", () => { registry.dispose(); }); + +it("keeps the previous results visible only while a refined or widened query is pending", () => { + const previous = { + query: "websock", + matches: [ + { + environmentId: envA, + threadId: ThreadId.make("thread-a"), + projectId: ProjectId.make("project-a"), + source: "user" as const, + snippet: "websocket reconnect", + messageCreatedAt: null, + }, + ], + }; + expect(visibleThreadSearch("websocke", null, previous)).toBe(previous); + expect(visibleThreadSearch("webso", null, previous)).toBe(previous); + expect(visibleThreadSearch("billing", null, previous)).toEqual({ query: "", matches: [] }); + const ready = { query: "websocke", matches: [] }; + expect(visibleThreadSearch("websocke", ready, previous)).toBe(ready); +}); + +it("ranks recent threads by this client's own latest open", () => { + const thread = { + environmentId: envA, + id: ThreadId.make("thread-local"), + title: "Local", + branch: null, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + latestUserMessageAt: null, + opens: { openedAt: 1_000, score: 1, picks: [] }, + }; + claimThreadOpen(envA, "thread-local", 5_000); + expect(threadSearchSubject(thread, null).opens?.openedAt).toBe(5_000); + // Also when the server has no record of opens, as an older server would not. + claimThreadOpen(envA, "thread-unrecorded", 6_000); + expect( + threadSearchSubject({ ...thread, id: ThreadId.make("thread-unrecorded"), opens: null }, null) + .opens, + ).toEqual({ openedAt: 6_000, score: 1, picks: [] }); +}); + +it("counts recency from what the user wrote, else from when the thread began", () => { + const thread = { + environmentId: envA, + id: ThreadId.make("thread-agent"), + title: "Launched by an agent", + branch: null, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-20T00:00:00.000Z", + latestUserMessageAt: "2026-10-20T00:00:00.000Z", + opens: null, + }; + expect( + threadSearchSubject({ ...thread, latestUserAuthoredMessageAt: null }, null).activityAt, + ).toBe(Date.parse("2026-10-01T00:00:00.000Z")); + expect( + threadSearchSubject( + { ...thread, latestUserAuthoredMessageAt: "2026-10-05T00:00:00.000Z" }, + null, + ).activityAt, + ).toBe(Date.parse("2026-10-05T00:00:00.000Z")); + // An older server omits the field. + expect(threadSearchSubject(thread, null).activityAt).toBe(Date.parse("2026-10-20T00:00:00.000Z")); +}); + +it("keeps shown message rows in place and leaves room for a slower environment", () => { + // The fast environment answers first and fills the limit. + const fast = Array.from({ length: 12 }, (_, index) => `a${index}`); + const first = nextShownOrder(null, "rust", fast, 10); + expect(first.keys.slice(0, first.shown)).toEqual(fast.slice(0, 10)); + // The slow one's matches rank first: shown rows hold, and its best few come next. + const slow = ["b0", "b1", "b2", "b3"]; + const second = nextShownOrder(first, "rust", [...slow, ...fast], 10); + expect(second.keys.slice(0, second.shown)).toEqual([...fast.slice(0, 10), "b0", "b1", "b2"]); + // Nothing new: nothing moves and nothing grows. + expect(nextShownOrder(second, "rust", [...slow, ...fast], 10)).toBe(second); + // A third environment gets no more room past the limit. + const third = nextShownOrder(second, "rust", ["c0", ...slow, ...fast], 10); + expect(third.shown).toBe(13); + expect(third.keys.slice(0, 13)).toEqual(second.keys.slice(0, 13)); + // A new query starts from its own ranking. + expect(nextShownOrder(second, "rusty", ["b0"], 10).keys).toEqual(["b0"]); +}); + +it("sends one open per thread every ten minutes by this client's clock", () => { + const minute = 60_000; + expect(claimThreadOpen(envA, "thread-open", 0)).toBe(true); + expect(claimThreadOpen(envA, "thread-open", 9 * minute)).toBe(false); + expect(claimThreadOpen(envB, "thread-open", 9 * minute)).toBe(true); + expect(claimThreadOpen(envA, "thread-open", 10 * minute)).toBe(true); +}); diff --git a/packages/client-runtime/src/state/threadSearch.ts b/packages/client-runtime/src/state/threadSearch.ts index c22cf9819d88..d03bd9a8de62 100644 --- a/packages/client-runtime/src/state/threadSearch.ts +++ b/packages/client-runtime/src/state/threadSearch.ts @@ -7,6 +7,37 @@ import { import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; +import { + foldSearchQuery, + THREAD_OPEN_DEDUPE_MS, + type ThreadSearchSubject, +} from "@t3tools/shared/paletteSearch"; +import { threadSearchPullRequests } from "@t3tools/shared/threadPullRequests"; + +import type { EnvironmentThreadShell } from "./models.ts"; +import { getThreadSortTimestamp } from "./threadSort.ts"; + +// When this client last sent an open per thread, by its own clock: a server +// timestamp would be off by any clock skew. The server merges opens closer +// together than THREAD_OPEN_DEDUPE_MS anyway, so skipping them saves a command +// and a broadcast while flipping between threads. +const sentThreadOpens = new Map(); +// When this client last opened each thread, sent or not, so recent threads +// follow quick switches the server merges. +const localThreadOpens = new Map(); +/** + * Records that this client opened a thread, and says whether to send the open + * to the server now. + */ +export function claimThreadOpen(environmentId: EnvironmentId, threadId: string, now: number) { + const key = `${environmentId}:${threadId}`; + localThreadOpens.set(key, now); + if (now - (sentThreadOpens.get(key) ?? Number.NEGATIVE_INFINITY) < THREAD_OPEN_DEDUPE_MS) { + return false; + } + sentThreadOpens.set(key, now); + return true; +} export interface EnvironmentThreadSearchMatch extends OrchestrationThreadSearchMatch { readonly environmentId: EnvironmentId; @@ -65,12 +96,7 @@ export function createThreadSearchResultsAtomFamily(options: { isLoading ||= result.waiting; const value = Option.getOrNull(AsyncResult.value(result)); if (value !== null) { - matches.push( - ...value.matches.map((match) => ({ - ...match, - environmentId, - })), - ); + matches.push(...value.matches.map((match) => ({ ...match, environmentId }))); } } @@ -78,3 +104,126 @@ export function createThreadSearchResultsAtomFamily(options: { }).pipe(Atom.withLabel(`${options.labelPrefix}:${key}`)), ); } + +export type ThreadSearchShell = Pick< + EnvironmentThreadShell, + | "environmentId" + | "id" + | "title" + | "branch" + | "createdAt" + | "updatedAt" + | "latestUserMessageAt" + | "latestUserAuthoredMessageAt" + | "opens" +> & + Partial>; + +/** + * What the web and mobile palettes rank a thread shell by. Pass the + * environment label only when there are two or more environments. + */ +export function threadSearchSubject( + thread: ThreadSearchShell, + projectTitle: string | null | undefined, + environmentLabel?: string | null, +): ThreadSearchSubject { + // What the user wrote, not agent wakes or task notices in the user role. A + // thread the user never wrote in counts from its creation; an older server + // omits the field, so the latest user-role message stands in. + const authored = thread.latestUserAuthoredMessageAt; + const activityAt = + authored === undefined + ? getThreadSortTimestamp(thread, "updated_at") + : Date.parse(authored ?? thread.createdAt); + const localOpenedAt = localThreadOpens.get(`${thread.environmentId}:${thread.id}`) ?? 0; + return { + id: thread.id, + title: thread.title, + branch: thread.branch, + projectTitle: projectTitle ?? null, + environmentLabel: environmentLabel ?? null, + pullRequests: threadSearchPullRequests(thread), + activityAt: Number.isFinite(activityAt) ? activityAt : null, + // This client's own latest open counts too: the server merged it, or has + // no record yet (an older server ignores opens). + opens: + localOpenedAt > (thread.opens?.openedAt ?? 0) + ? { score: 1, picks: [], ...thread.opens, openedAt: localOpenedAt } + : (thread.opens ?? null), + }; +} + +/** Message result keys in the order a search box last showed them for `query`. */ +export interface ShownOrder { + readonly query: string; + readonly keys: ReadonlyArray; + /** How many of `keys` are on screen; only those hold their places. */ + readonly shown: number; +} + +// Rows past the limit a slower environment's best matches may take. +const LATE_ROOM = 3; + +/** + * The order to show message results in, and how many: rows already shown for + * this query keep their places, and results that arrive later, from a slower + * environment, go below them with up to LATE_ROOM rows past `limit`. Nothing + * moves under the pointer or the keyboard highlight, and a late best match + * still shows. Returns `previous` when nothing changed, so it can sit in React + * state. + */ +export function nextShownOrder( + previous: ShownOrder | null, + query: string, + ranked: ReadonlyArray, + limit: number, +): ShownOrder { + if (previous?.query !== query) { + return { query, keys: ranked, shown: Math.min(limit, ranked.length) }; + } + const listed = new Set(ranked); + const kept = previous.keys.slice(0, previous.shown).filter((key) => listed.has(key)); + const held = new Set(kept); + const seen = new Set(previous.keys); + const rest = ranked.filter((key) => !held.has(key)); + const arrived = rest.filter((key) => !seen.has(key)).length; + const keys = [...kept, ...rest]; + const shown = Math.min( + keys.length, + limit + LATE_ROOM, + Math.max(limit, kept.length + Math.min(arrived, LATE_ROOM)), + ); + const unchanged = + shown === previous.shown && + keys.length === previous.keys.length && + keys.every((key, index) => key === previous.keys[index]); + return unchanged ? previous : { query, keys, shown }; +} + +export interface SettledThreadSearch { + readonly query: string; + readonly matches: ReadonlyArray; +} + +/** + * What a search box shows for `query`: its own results once they arrive + * (`ready`), else the previous results if `query` refines or widens their + * query, so result rows do not blink out and back on every keystroke. + * Results for an unrelated query clear. + */ +export function visibleThreadSearch( + query: string, + ready: SettledThreadSearch | null, + previous: SettledThreadSearch | null, +): SettledThreadSearch { + if (ready !== null) return ready; + const current = foldSearchQuery(query); + const before = foldSearchQuery(previous?.query ?? ""); + return previous !== null && + current.length > 0 && + before.length > 0 && + (current.startsWith(before) || before.startsWith(current)) + ? previous + : { query: "", matches: [] }; +} diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index da7ddc41385a..c193ccd943e4 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -10,6 +10,7 @@ import { OrchestrationV2ProviderThreadJson, OrchestrationV2RpcSchemas, OrchestrationV2Subagent, + OrchestrationV2ThreadShellJson, OrchestrationV2SubagentJson, OrchestrationV2ThreadHistoryPage, OrchestrationV2TurnItem, @@ -419,6 +420,46 @@ describe("orchestration V2 contracts", () => { }); }); +describe("thread open stats", () => { + it("reads a thread whose open stats this build cannot read, without them", () => { + const shell = { + id: "thread-test", + projectId: "project-test", + title: "Thread", + providerInstanceId: "codex", + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { rootThreadId: "thread-test", parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + createdBy: "user", + creationSource: "mobile", + latestRunId: null, + activeRunId: null, + status: "idle", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + }; + const decode = Schema.decodeUnknownSync(OrchestrationV2ThreadShellJson); + const opens = { openedAt: 1, score: 1, picks: [{ query: "upl", at: 1 }] }; + expect(decode({ ...shell, opens }).opens).toEqual(opens); + expect(decode({ ...shell, opens: { openedAt: "2026-10-01", score: 1 } }).opens).toBeNull(); + }); +}); + describe("background work kinds from older or newer servers", () => { const storedNotification = (source: unknown) => ({ id: "notification", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index ff7c295b8bf9..550da8985dff 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -370,6 +370,28 @@ export const OrchestrationV2LimitRecoveryUpdate = Schema.Struct({ ); export type OrchestrationV2LimitRecoveryUpdate = typeof OrchestrationV2LimitRecoveryUpdate.Type; +/** + * How often the user opens a thread, for search ranking. `score` is an open + * count that halves every two weeks, as of `openedAt`. `picks` holds the last + * few palette queries that picked the thread, folded to lowercase, newest + * first, each with when it was picked. Times are epoch milliseconds: ranking + * does arithmetic on them. + */ +export const OrchestrationV2ThreadOpens = Schema.Struct({ + openedAt: Schema.Finite, + score: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)), + picks: Schema.Array( + Schema.Struct({ query: Schema.String.check(Schema.isMaxLength(32)), at: Schema.Finite }), + ).check(Schema.isMaxLength(3)), +}); +export type OrchestrationV2ThreadOpens = typeof OrchestrationV2ThreadOpens.Type; + +// Open stats only rank search results, so a value this build cannot read (an +// older or newer shape) becomes null instead of failing the whole thread. +const LenientThreadOpens = Schema.NullOr(OrchestrationV2ThreadOpens).pipe( + Schema.catchDecoding(() => Effect.succeedSome(null)), +); + export const OrchestrationV2AppThread = Schema.Struct({ ...OrchestrationV2CreationFields, id: ThreadId, @@ -419,6 +441,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + opens: Schema.optional(LenientThreadOpens), titleRegeneration: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -1877,6 +1900,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + opens: Schema.optional(LenientThreadOpens), titleRegeneration: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -2691,6 +2715,10 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, visitedAt: IsoDateTime, + // The user opened the thread, as opposed to new activity on an open one. + // Older servers ignore both fields and only move the read watermark. + opened: Schema.optional(Schema.Boolean), + searchQuery: Schema.optional(Schema.String.check(Schema.isMaxLength(200))), }), Schema.Struct({ type: Schema.Literal("thread.mark-unread"), diff --git a/packages/contracts/src/threadSearch.ts b/packages/contracts/src/threadSearch.ts index d3b8f6badee0..42c87ebb610c 100644 --- a/packages/contracts/src/threadSearch.ts +++ b/packages/contracts/src/threadSearch.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, + MessageId, ProjectId, ThreadId, TrimmedNonEmptyString, @@ -11,10 +12,16 @@ import { export const OrchestrationThreadSearchSource = Schema.Literals(["user", "assistant"]); export type OrchestrationThreadSearchSource = typeof OrchestrationThreadSearchSource.Type; +/** Longer queries are cut to this before they are sent. */ +export const THREAD_SEARCH_QUERY_MAX_LENGTH = 200; + // The server's SQLite client is synchronous and single-connection. Bound both // scan input and response size so a search cannot monopolize that connection. export const OrchestrationSearchThreadsInput = Schema.Struct({ - query: TrimmedString.check(Schema.isMinLength(2), Schema.isMaxLength(200)), + query: TrimmedString.check( + Schema.isMinLength(2), + Schema.isMaxLength(THREAD_SEARCH_QUERY_MAX_LENGTH), + ), limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 }))), }); export type OrchestrationSearchThreadsInput = typeof OrchestrationSearchThreadsInput.Type; @@ -25,6 +32,17 @@ export const OrchestrationThreadSearchMatch = Schema.Struct({ source: OrchestrationThreadSearchSource, snippet: Schema.String.check(Schema.isMaxLength(240)), messageCreatedAt: Schema.NullOr(IsoDateTime), + /** + * How well the message matches: 1 for whole words, 0.8 when a word only + * starts one, 0.3 when one only appears inside words, 0.25 more when the + * words are written together as typed, else 0.1 more when they are near. + * Older servers omit it. + */ + quality: Schema.optionalKey(Schema.Finite), + /** How many of the thread's newest messages match. Older servers omit it. */ + hits: Schema.optionalKey(Schema.Int), + /** The message the snippet is from. Older servers omit it. */ + messageId: Schema.optionalKey(MessageId), }); export type OrchestrationThreadSearchMatch = typeof OrchestrationThreadSearchMatch.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 5fa05fda74bb..0254443e1b46 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -243,6 +243,10 @@ "types": "./src/threadReference.ts", "import": "./src/threadReference.ts" }, + "./paletteSearch": { + "types": "./src/paletteSearch.ts", + "import": "./src/paletteSearch.ts" + }, "./threadPullRequests": { "types": "./src/threadPullRequests.ts", "import": "./src/threadPullRequests.ts" diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts new file mode 100644 index 000000000000..644a40912640 --- /dev/null +++ b/packages/shared/src/paletteSearch.test.ts @@ -0,0 +1,740 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + findSearchTokenRanges, + foldSearchQuery, + pickedQuery, + rankMessageMatches, + rankSearchGroups, + rankSearchItems, + recordThreadOpen, + messageMatcher, + scoreSearchTerms, + scoreThreadSearch, + SEARCH_RECENCY_HALF_LIFE_MS, + type SearchTermsMatch, + type ThreadSearchSubject, +} from "./paletteSearch.ts"; + +const NOW = Date.UTC(2026, 9, 23); +const scoreMessageMatch = (text: string, query: string) => messageMatcher(query)(text); +const DAY = 24 * 60 * 60 * 1000; + +function thread(title: string, overrides: Partial = {}): ThreadSearchSubject { + return { title, activityAt: NOW - 30 * DAY, ...overrides }; +} + +function rankTitles(query: string, threads: ReadonlyArray): string[] { + const normalized = foldSearchQuery(query); + return rankSearchItems(threads, (subject, options) => + scoreThreadSearch(normalized, subject, NOW, options), + ).map(({ item }) => item.title); +} + +describe("scoreThreadSearch", () => { + it("ranks whole words over prefixes over newer mid-word matches", () => { + expect( + rankTitles("auth", [ + thread("Fix oauth redirect", { activityAt: NOW }), + thread("Authentication cleanup"), + thread("Share auth across worktrees"), + ]), + ).toEqual(["Share auth across worktrees", "Authentication cleanup", "Fix oauth redirect"]); + }); + + it("treats camelCase, kebab, path, and digit boundaries as word starts", () => { + const starts = (title: string, query: string) => + scoreThreadSearch(query, thread(title), NOW)?.ranges; + expect(starts("useThreadSearch hook", "search")).toEqual([{ start: 9, end: 15 }]); + expect(starts("fix/sidebar-perf", "perf")).toEqual([{ start: 12, end: 16 }]); + expect(starts("Upgrade to pnpm12", "12")).toEqual([{ start: 15, end: 17 }]); + expect(starts("Fix JSONSchema parsing", "sch")).toEqual([{ start: 8, end: 11 }]); + expect(starts("Fix HTTPServer crash", "ser")).toEqual([{ start: 8, end: 11 }]); + expect(starts("Rename index.ts", ".ts")).toEqual([{ start: 12, end: 15 }]); + // A match is whole only where a word ends: "githuba" does not fill "GitHubActions". + expect(scoreThreadSearch("githuba", thread("GitHubActions setup"), NOW)?.quality).toBeLessThan( + 1, + ); + expect(rankTitles(".ts", [thread("Fix parser.tsx"), thread("Rename index.ts")])).toEqual([ + "Rename index.ts", + "Fix parser.tsx", + ]); + // Short tokens never match mid-word. + expect(scoreThreadSearch("ci", thread("Decision log"), NOW)).toBeNull(); + }); + + it("lets recent activity and frequent opens decide between equal matches", () => { + expect( + rankTitles("goldbug", [ + thread("Goldbug epilogue", { activityAt: NOW - 20 * DAY }), + thread("Goldbug processes", { activityAt: NOW - DAY }), + ]), + ).toEqual(["Goldbug processes", "Goldbug epilogue"]); + expect( + rankTitles("goldbug", [ + thread("Goldbug processes", { activityAt: NOW - DAY }), + thread("Goldbug notes", { + activityAt: NOW - 40 * DAY, + opens: { openedAt: NOW - DAY, score: 12, picks: [] }, + }), + ]), + ).toEqual(["Goldbug notes", "Goldbug processes"]); + }); + + it("puts the thread picked for a query first when that query is typed again", () => { + const threads = [ + thread("Release checklist", { + opens: { openedAt: NOW - 5 * DAY, score: 1, picks: [{ query: "rel", at: NOW - 5 * DAY }] }, + }), + thread("Relay pairing", { activityAt: NOW }), + ]; + expect(rankTitles("rel", threads)).toEqual(["Release checklist", "Relay pairing"]); + // A pick never lifts a match inside a word above a word match. + expect( + rankTitles("auth", [ + thread("Fix oauth redirect", { + opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "auth", at: NOW - DAY }] }, + }), + thread("Share auth across worktrees"), + ]), + ).toEqual(["Share auth across worktrees", "Fix oauth redirect"]); + // Two letters are too little to go on. + expect(rankTitles("re", threads)).toEqual(["Relay pairing", "Release checklist"]); + // Once the query leaves the pick behind, the better match wins again. + expect(rankTitles("rela", threads)).toEqual(["Relay pairing"]); + // An old pick fades with the thread's last open. + const stale = [ + thread("Release checklist", { + opens: { + openedAt: NOW - 90 * DAY, + score: 1, + picks: [{ query: "rel", at: NOW - 90 * DAY }], + }, + }), + thread("Relay pairing", { activityAt: NOW }), + ]; + expect(rankTitles("rel", stale)).toEqual(["Relay pairing", "Release checklist"]); + // Opening an old pick some other way does not revive it over a newer pick. + const reopened = [ + thread("Release checklist", { + opens: { openedAt: NOW - DAY, score: 2, picks: [{ query: "rel", at: NOW - 30 * DAY }] }, + }), + thread("Relay pairing", { + opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "rel", at: NOW - DAY }] }, + }), + ]; + expect(rankTitles("rel", reopened)).toEqual(["Relay pairing", "Release checklist"]); + // The latest pick for a query wins over an earlier one with a better title match. + const repicked = [ + thread("Deploy", { + opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "deploy", at: NOW - DAY }] }, + }), + thread("Deploy timeout on Railway", { + opens: { openedAt: NOW, score: 1, picks: [{ query: "deploy", at: NOW }] }, + }), + ]; + expect(rankTitles("deploy", repicked)).toEqual(["Deploy timeout on Railway", "Deploy"]); + // A thread's exact pick counts even after a newer pick for a longer query. + const exact = [ + thread("Fix browser", { + opens: { + openedAt: NOW, + score: 2, + picks: [ + { query: "fix browser", at: NOW }, + { query: "fix", at: NOW - 2 * DAY }, + ], + }, + }), + thread("Fix sidebar", { + opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "fix", at: NOW - DAY }] }, + }), + ]; + expect(rankTitles("fix", exact)).toEqual(["Fix sidebar", "Fix browser"]); + // A pick for a longer query does not pass the thread named by the query. + const named = [ + thread("Lakebed perf audit", { + opens: { openedAt: NOW, score: 1, picks: [{ query: "lakebed perf", at: NOW }] }, + }), + thread("Lakebed"), + ]; + expect(rankTitles("lakebed", named)).toEqual(["Lakebed", "Lakebed perf audit"]); + }); + + it("finds a branch pasted with the app's worktree prefix", () => { + const subject = thread("Cmd+K search", { branch: "t3/cmdk-search-v2" }); + expect(scoreThreadSearch(foldSearchQuery("t3/cmdk-search-v2"), subject, NOW)).not.toBeNull(); + expect(scoreThreadSearch(foldSearchQuery("t3/cmdk"), subject, NOW)).not.toBeNull(); + }); + + it("ranks a title holding the words above the app's own branch prefix and project name", () => { + const busy = { activityAt: NOW, opens: { openedAt: NOW, score: 6, picks: [] } }; + expect( + rankTitles("t3 connect", [ + thread("Diagnose Connect provisioning failures", { + ...busy, + branch: "t3/diagnose-connect", + projectTitle: "t3code", + }), + thread("T3 Connect Tunnel Lifecycle Review", { projectTitle: "t3code" }), + ])[0], + ).toBe("T3 Connect Tunnel Lifecycle Review"); + expect( + rankTitles("t3code", [ + thread("Fix sidebar lag", { ...busy, projectTitle: "t3code", branch: "t3code/sidebar" }), + thread("T3 Code vs Gemini Token Volume", { projectTitle: "slophouse" }), + ])[0], + ).toBe("T3 Code vs Gemini Token Volume"); + }); + + it("matches pull request numbers, branches, and projects with every token somewhere", () => { + const subject = thread("Trim model lists", { + branch: "t3/trim-mcp-models", + projectTitle: "t3code", + pullRequests: [{ number: 22889, title: "Smaller MCP payloads" }], + }); + for (const query of ["#22889", "22889", "trim mcp", "payloads t3code", "models trim"]) { + expect(scoreThreadSearch(foldSearchQuery(query), subject, NOW), query).not.toBeNull(); + } + expect(scoreThreadSearch("trim lakebed", subject, NOW)).toBeNull(); + }); + + it("forgives one typo only when nothing matches exactly", () => { + const threads = [thread("Hosting alternatives to Railway"), thread("Trim MCP model lists")]; + expect(rankTitles("railwya", threads)).toEqual(["Hosting alternatives to Railway"]); + expect(rankTitles("trim mpc", threads)).toEqual(["Trim MCP model lists"]); + expect( + rankTitles("drga", [thread("Restore drag handles"), thread("Organize indicators")]), + ).toEqual(["Restore drag handles"]); + expect(rankTitles("22889", [thread("Stats", { pullRequests: [{ number: 22189 }] })])).toEqual( + [], + ); + // Words with digits still forgive one. + expect(rankTitles("t3coed", [thread("t3code perf audit"), thread("Other")])).toEqual([ + "t3code perf audit", + ]); + expect(rankTitles("oath2", [thread("Fix oauth2 redirect")])).toEqual(["Fix oauth2 redirect"]); + // A typo beats a mid-word accident: "ilde" is "idle", not the middle of "Builder". + expect( + rankTitles("ilde", [thread("Typesafe App Builder"), thread("De-risk idle tunnel cleanup")]), + ).toEqual(["De-risk idle tunnel cleanup", "Typesafe App Builder"]); + // "ing" is not "Nightly": a three-letter typo keeps the first letter. + expect(rankTitles("ing", [thread("Nightly build fix")])).toEqual([]); + // A short word may miss one letter while it is typed, but not change one. + expect(rankTitles("srv", [thread("Server restart"), thread("Other")])).toEqual([ + "Server restart", + ]); + expect(rankTitles("sidb", [thread("Fix sidebar lag")])).toEqual(["Fix sidebar lag"]); + expect(rankTitles("cron", [thread("Config cleanup")])).toEqual([]); + // Filler words in a phrase-style query are not required, but alone they are. + expect(rankTitles("fix the sidebar", [thread("Fix Sidebar Lag")])).toEqual(["Fix Sidebar Lag"]); + expect(rankTitles("the", [thread("Fix sidebar lag")])).toEqual([]); + // A typed straight apostrophe finds a curly one. + expect(rankTitles("don't", [thread("Don’t merge yet")])).toEqual(["Don’t merge yet"]); + // Typos count across camelCase joins. + expect(rankTitles("githbu", [thread("GitHub API usage"), thread("Other")])).toEqual([ + "GitHub API usage", + ]); + expect(rankTitles("macso", [thread("Fix macOS window"), thread("Other")])).toEqual([ + "Fix macOS window", + ]); + // "enter" is not "external" when the right thread is already there. + expect( + rankTitles("cmd enter", [thread("Fix Cmd+Enter"), thread("Open links external")]), + ).toEqual(["Fix Cmd+Enter"]); + }); +}); + +describe("queries that fold to empty words", () => { + it("returns for a hash and a space, or marks that fold to nothing", () => { + expect(rankTitles("# fix", [thread("Fix sidebar lag")])).toEqual(["Fix sidebar lag"]); + expect(rankTitles("#", [thread("Fix sidebar lag")])).toEqual([]); + expect(rankTitles("´´´", [thread("Fix sidebar lag")])).toEqual([]); + expect(scoreSearchTerms("# fix", ["Fix sidebar"])).toBeNull(); + expect(findSearchTokenRanges("Fix sidebar", "´´´")).toEqual([]); + expect(scoreMessageMatch("Fix sidebar", "´´´")).toBeNull(); + }); +}); + +describe("scoreThreadSearch beyond Latin text and titles", () => { + it("leaves out a row whose words only meet across unrelated fields", () => { + const scattered = thread("eMudhra certificate update request", { + pullRequests: [ + { number: 22645, title: "fix model picker" }, + { number: 22649, title: "CI speedups" }, + ], + }); + expect(scoreThreadSearch("fix ci", scattered, NOW)).toBeNull(); + // The project explains its own words; one field explains the rest. + const lakebed = thread("Weekly notes", { + projectTitle: "lakebed", + pullRequests: [{ number: 243, title: "perf: faster cold starts" }], + }); + expect(scoreThreadSearch("lakebed perf", lakebed, NOW)?.explain?.()).toEqual({ + label: "PR", + text: "#243 perf: faster cold starts", + ranges: [{ start: 5, end: 9 }], + }); + }); + + it("says which field matched when the title does not", () => { + const subject = thread("Trim model lists", { + branch: "t3/cleanup-payloads", + pullRequests: [{ number: 22889, title: "Smaller MCP payloads" }], + }); + for (const query of ["#22889", "pr 22889", "pr22889", "pull request #22889"]) { + expect(scoreThreadSearch(foldSearchQuery(query), subject, NOW)?.explain?.(), query).toEqual({ + label: "PR", + text: "#22889 Smaller MCP payloads", + ranges: [{ start: 1, end: 6 }], + }); + } + // The app's own worktree prefix is not part of what the branch says. + expect(scoreThreadSearch("cleanup", subject, NOW)?.explain?.()).toEqual({ + label: "Branch", + text: "cleanup-payloads", + ranges: [{ start: 0, end: 7 }], + }); + expect(scoreThreadSearch("trim", subject, NOW)?.explain?.()).toBeUndefined(); + }); + + it("reads letters and numbers typed together, without forgiving typos in numbers", () => { + const threads = [ + thread("Upgrade to pnpm 12"), + thread("GPT-5 eval"), + thread("iOS 26 support"), + thread("Other"), + ]; + expect(rankTitles("pnpm12", threads)).toEqual(["Upgrade to pnpm 12"]); + expect(rankTitles("gpt5", threads)).toEqual(["GPT-5 eval"]); + expect(rankTitles("ios26", threads)).toEqual(["iOS 26 support"]); + expect(rankTitles("pnpm13", threads)).toEqual([]); + }); + + it("reads whole words typed together, but not scattered letters", () => { + const threads = [ + thread("VS Code extension settings"), + thread("Fix UI bug in sidebar"), + thread("I plan to open source all of lakebed in the next few weeks"), + ]; + expect(rankTitles("vscode", threads)).toEqual(["VS Code extension settings"]); + expect(rankTitles("fixuibug", threads)).toEqual(["Fix UI bug in sidebar"]); + expect(rankTitles("lint", threads)).toEqual([]); + // Short words before a mere prefix are a coincidence. + expect(rankTitles("inth", [thread("Show skill names in thread overview")])).toEqual([]); + expect(rankTitles("tos", [thread("Move Client State to Server")])).toEqual([]); + expect(rankTitles("t3co", [thread("T3 Code vs Gemini")])).toEqual(["T3 Code vs Gemini"]); + expect(rankTitles('"sidebar lag"', [thread("Fix sidebar lag")])).toEqual(["Fix sidebar lag"]); + // A pasted query with a separate accent mark matches the composed title. + expect(rankTitles("cafe\u0301", [thread("Café menu")])).toEqual(["Café menu"]); + // Three letters of the last word are enough, as it is typed. + expect(rankTitles("gomod", [thread("Migrate to Go modules")])).toEqual([ + "Migrate to Go modules", + ]); + // Joined words beat an earlier match inside a word. + expect(scoreThreadSearch("vscode", thread("Fixvscode and VS Code"), NOW)?.quality).toBe(0.6); + // A strong match elsewhere does not hide joined words. + expect( + rankTitles("vscode", [ + thread("VS Code remote support"), + thread("Old work", { branch: "vscode", activityAt: NOW - 400 * DAY }), + ]), + ).toContain("VS Code remote support"); + }); + + it("stores picked queries without splitting an emoji", () => { + expect(pickedQuery(`${"a".repeat(31)}🚀`)).toBe("a".repeat(31)); + expect(pickedQuery(" Upl ")).toBe("upl"); + // Folded before it is cut, as stored and as compared. + const spaced = "investigate workspace thread reset bug"; + expect(pickedQuery(spaced)).toBe(pickedQuery(foldSearchQuery(spaced))); + }); + + it("matches emoji in titles", () => { + expect(rankTitles("🚀", [thread("🚀 Launch checklist"), thread("Launch notes")])).toEqual([ + "🚀 Launch checklist", + ]); + }); + + it("treats each Chinese or Japanese character as a word start", () => { + expect(scoreThreadSearch("搜索", thread("实现全文搜索"), NOW)).not.toBeNull(); + expect(scoreThreadSearch("検索", thread("新しい検索機能"), NOW)).not.toBeNull(); + }); +}); + +describe("scoreThreadSearch with heavy use and loose input", () => { + it("keeps a whole word on a quiet thread above a mid-word hit on a thread opened daily", () => { + let opens = recordThreadOpen(null, NOW - 60 * DAY, undefined); + for (let day = 59; day >= 1; day--) opens = recordThreadOpen(opens, NOW - day * DAY, undefined); + expect( + rankTitles("auth", [ + thread("Fix oauth redirect", { activityAt: NOW, opens }), + thread("Share auth across worktrees", { activityAt: NOW - 90 * DAY }), + ]), + ).toEqual(["Share auth across worktrees", "Fix oauth redirect"]); + }); + + it("reads a word and the start of the next typed together, highlighting each part", () => { + const threads = [thread("Fix sidebar lag"), thread("Unrelated work")]; + expect(rankTitles("fixsid", threads)).toEqual(["Fix sidebar lag"]); + // A later "F" can start the run. + expect(rankTitles("fixsid", [thread("Feature: Fix sidebar lag")])).toEqual([ + "Feature: Fix sidebar lag", + ]); + expect(scoreThreadSearch("fixsid", threads[0]!, NOW)?.ranges).toEqual([ + { start: 0, end: 3 }, + { start: 4, end: 7 }, + ]); + // Initials are not words typed together. + expect(rankTitles("fsl", threads)).toEqual([]); + }); + + it("finds nothing rather than an unrelated thread when no word matches", () => { + const threads = [ + thread("Projectless threads without Git"), + thread("Concern about connection setup"), + thread("Simplify worktree thread creation"), + thread("Start a new conversation"), + thread("Setting up the sentinel"), + thread("I plan to open source all of lakebed in the next few weeks"), + thread("Design notes"), + thread("Link pull request to thread"), + ]; + for (const query of ["posthog", "cron", "stripe", "gpu", "fxsb", "lint", "deno"]) { + expect(rankTitles(query, threads), query).toEqual([]); + } + }); + + it("matches pieces of references that hold a number, and environment names", () => { + const subject = thread("Trim model lists", { + id: "thread-5fe6f59c-4976", + environmentLabel: "BeeBox", + pullRequests: [ + { + number: 22889, + references: ["pingdotgg/t3code#22889", "https://github.com/pingdotgg/t3code/pull/22889"], + }, + ], + }); + for (const query of [ + "t3code#22889", + "github.com/pingdotgg/t3code/pull/22889", + "https://github.com/pingdotgg/t3code/pull/22889/files", + "5fe6f59c", + "beebox", + ]) { + expect(scoreThreadSearch(foldSearchQuery(query), subject, NOW), query).not.toBeNull(); + } + expect(scoreThreadSearch("github.com", subject, NOW)).toBeNull(); + // A number is never cut short. + for (const query of ["github.com/pingdotgg/t3code/pull/2288", "t3code#2288"]) { + expect(scoreThreadSearch(foldSearchQuery(query), subject, NOW), query).toBeNull(); + } + }); +}); + +describe("rankMessageMatches", () => { + it("merges environments by word match, then picks, then quality and use", () => { + const subjects = new Map([ + ["a-word", thread("A word")], + ["a-word-2", thread("A word too")], + ["b-prefix", thread("B prefix")], + ["b-recent", thread("B recent", { activityAt: NOW })], + [ + "b-picked", + thread("B picked", { + opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "needle", at: NOW - DAY }] }, + }), + ], + ]); + // Environment A sent two whole words, environment B a prefix and two more. + const matches = [ + { key: "a-word", quality: 1 }, + { key: "a-word-2", quality: 1 }, + { key: "b-prefix", quality: 0.8 }, + { key: "b-recent", quality: 1 }, + { key: "b-picked", quality: 0.8 }, + ]; + expect( + rankMessageMatches(matches, (match) => subjects.get(match.key), "needle", NOW).map( + (match) => match.key, + ), + ).toEqual(["b-picked", "b-recent", "a-word", "a-word-2", "b-prefix"]); + // A palette lists title matches above, so they use up no message rows. + const titled = new Map([...subjects, ["c-titled", thread("Needle cleanup")]]); + expect( + rankMessageMatches( + [{ key: "c-titled", quality: 1 }, ...matches], + (match) => titled.get(match.key), + "needle", + NOW, + { withoutTitleMatches: true }, + ).map((match) => match.key), + ).not.toContain("c-titled"); + }); + + it("ranks a thread that keeps using the word above a passing mention of equal quality", () => { + // The real case: 21 hits seven weeks back against one this week, opened today. + const subjects = new Map([ + [ + "mention", + thread("Mention", { + activityAt: NOW - 3 * DAY, + opens: { openedAt: NOW, score: 1, picks: [] }, + }), + ], + ["about", thread("About it", { activityAt: NOW - 48 * DAY })], + ]); + const matches = [ + { key: "mention", quality: 1, hits: 1 }, + { key: "about", quality: 1, hits: 21 }, + ]; + expect( + rankMessageMatches(matches, (match) => subjects.get(match.key), "tailscale", NOW).map( + (match) => match.key, + ), + ).toEqual(["about", "mention"]); + }); + + it("keeps matches inside words below word matches, however used", () => { + const busy = thread("Busy", { + activityAt: NOW, + opens: { openedAt: NOW, score: 20, picks: [] }, + }); + const matches = [ + { key: "busy", quality: 0.3 }, + { key: "quiet", quality: 1 }, + ]; + const subjects = new Map([ + ["busy", busy], + ["quiet", thread("Quiet")], + ]); + expect( + rankMessageMatches(matches, (match) => subjects.get(match.key), "rust", NOW).map( + (match) => match.key, + ), + ).toEqual(["quiet", "busy"]); + }); +}); + +describe("rankSearchGroups", () => { + it("orders groups by their best match and keeps the given order on ties", () => { + const groups = rankSearchGroups( + [ + { group: "actions", items: ["Open settings", "New thread"] }, + { group: "threads", items: ["Settings sync rewrite", "Thread settings"] }, + { group: "projects", items: ["Settings app"] }, + ], + (item, options) => scoreSearchTerms("settings", [item], options), + ); + expect(groups.map(({ group, ranked }) => [group, ranked.map(({ item }) => item)])).toEqual([ + ["actions", ["Open settings"]], + ["threads", ["Settings sync rewrite", "Thread settings"]], + ["projects", ["Settings app"]], + ]); + }); +}); + +describe("rankSearchGroups with keywords and picks", () => { + it("matches inside a word only in thread titles", () => { + expect(scoreSearchTerms("arch", ["Search project contents"])).toBeNull(); + expect( + scoreThreadSearch("arch", thread("Notes", { branch: "research-extension-system" }), NOW), + ).toBeNull(); + expect(scoreThreadSearch("arch", thread("Research notes"), NOW)?.quality).toBe(0.3); + }); + + it("forgives typos in names but not in hidden keywords", () => { + const groups = rankSearchGroups( + [ + { group: "actions", items: [["Open settings", "preferences", "keybindings"]] }, + { group: "settings", items: [["Keybindings", "General"]] }, + ], + (terms, options) => scoreSearchTerms("keybindngs", terms, options), + ); + expect(groups.map(({ group }) => group)).toEqual(["settings"]); + expect(scoreSearchTerms("duckdb", ["Search engine", "duckduckgo"], { typos: true })).toBeNull(); + expect(scoreSearchTerms("rust", ["GitHub sharing", "trusted"])).toBeNull(); + // Filler words do not make a short query long enough for keyword prefixes. + expect(scoreSearchTerms("the ci", ["Context window indicator", "circle"])).toBeNull(); + }); + + it("lists a thread named for the query above a command with it as a keyword", () => { + const rank = (command: string[], subject: ThreadSearchSubject) => + rankSearchGroups( + [ + { group: "actions", items: [command] }, + { group: "threads", items: [subject] }, + ], + (item, options) => { + if (Array.isArray(item)) return scoreSearchTerms("mcp", item, options); + return scoreThreadSearch("mcp", item, NOW, options); + }, + ).map(({ group }) => group); + const named = thread("Trim MCP model lists"); + const picked = thread("Trim MCP model lists", { + opens: { openedAt: NOW, score: 1, picks: [{ query: "mcp", at: NOW }] }, + }); + expect(rank(["Restart agent session", "reload", "mcp"], named)).toEqual(["threads", "actions"]); + // A command named for the query stays first; a pick reorders threads but + // never moves their group, so one pick for "mcp" does not pass the command. + expect(rank(["Reload MCP servers"], named)).toEqual(["actions", "threads"]); + expect(rank(["Reload MCP servers"], picked)).toEqual(["actions", "threads"]); + }); +}); + +describe("recordThreadOpen", () => { + it("decays the open count, merges quick reopens, and keeps the latest picked queries", () => { + const first = recordThreadOpen(null, NOW, " Upl "); + expect(first).toEqual({ openedAt: NOW, score: 1, picks: [{ query: "upl", at: NOW }] }); + const reopened = recordThreadOpen(first, NOW + 60_000, "uploadthing"); + expect(reopened).toEqual({ + openedAt: NOW, + score: 1, + picks: [ + { query: "uploadthing", at: NOW + 60_000 }, + { query: "upl", at: NOW }, + ], + }); + const later = recordThreadOpen(reopened, NOW + SEARCH_RECENCY_HALF_LIFE_MS, "x".repeat(50)); + expect(later.score).toBe(1.5); + expect(later.picks.map((pick) => pick.query)).toEqual(["x".repeat(32), "uploadthing", "upl"]); + const repicked = recordThreadOpen(later, later.openedAt + DAY, "upl"); + expect(repicked.picks).toEqual([ + { query: "upl", at: later.openedAt + DAY }, + { query: "x".repeat(32), at: later.openedAt }, + { query: "uploadthing", at: NOW + 60_000 }, + ]); + }); + + it("drops picks that no longer count when the thread is opened again", () => { + const opens = { + openedAt: NOW - 30 * DAY, + score: 1, + picks: [{ query: "old", at: NOW - 30 * DAY }], + }; + expect(recordThreadOpen(opens, NOW, "new").picks).toEqual([{ query: "new", at: NOW }]); + }); + + it("does not renew a query's pick when the thread is picked for another one", () => { + const deploy = [ + thread("Deploy notes", { + opens: { + openedAt: NOW, + score: 2, + picks: [ + { query: "scripts", at: NOW }, + { query: "deploy", at: NOW - 60 * DAY }, + ], + }, + }), + thread("Deploy timeout", { + opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "deploy", at: NOW - DAY }] }, + }), + ]; + expect(rankTitles("deploy", deploy)).toEqual(["Deploy timeout", "Deploy notes"]); + }); +}); + +describe("findSearchTokenRanges", () => { + it("maps folded matches back to the source text and skips short tokens", () => { + const text = "Le Café résumé ci"; + expect(findSearchTokenRanges(text, "CAFE resume ci")).toEqual([ + { start: 3, end: 7 }, + { start: 8, end: 14 }, + ]); + }); + + it("highlights a three-letter token only where it starts a word", () => { + expect(findSearchTokenRanges("drafts, enableFTS", "fts")).toEqual([{ start: 14, end: 17 }]); + expect(findSearchTokenRanges("Open handlers.ts", ".ts")).toEqual([{ start: 13, end: 16 }]); + }); +}); + +describe("scoreMessageMatch", () => { + it("needs short words at a word start and ranks other words found inside words lower", () => { + const text = `Saved the drafts. ${"More notes. ".repeat(40)}Then enableFTS and Enable FTS.`; + const fts = scoreMessageMatch(text, "fts"); + expect(fts?.quality).toBe(1); + expect(text.slice(fts?.offset)).toMatch(/^FTS and/); + expect(scoreMessageMatch("saved two drafts", "fts")).toBeNull(); + expect(scoreMessageMatch("We trust the build", "rust")).toEqual({ quality: 0.3, offset: 4 }); + expect(scoreMessageMatch("Rust and trust", "rust")).toEqual({ quality: 1, offset: 0 }); + expect(scoreMessageMatch("Use the sidebar", '"sidebar"')).toEqual({ quality: 1, offset: 8 }); + // Words written together as typed rank above the same words apart, and + // the snippet starts where they are together. + const together = "Run the tests, then vp run dev to check"; + expect(scoreMessageMatch(together, "vp run dev")).toEqual({ + quality: 1.25, + offset: together.indexOf("vp run dev"), + }); + // Near each other ranks above far apart. + expect(scoreMessageMatch("dev notes: vp is slow, run it", "vp run dev")?.quality).toBe(1.1); + const far = `run the tests. ${"More notes. ".repeat(10)}Then dev mode, then vp`; + expect(scoreMessageMatch(far, "vp run dev")?.quality).toBe(1); + expect(scoreMessageMatch("I don\u2019t know why", "don't know")?.quality).toBe(1.25); + expect(scoreMessageMatch("Le Café open late", "cafe open")?.quality).toBe(1.25); + expect(scoreMessageMatch("The cafe open late", "café open")?.quality).toBe(1.25); + // The snippet starts where the words meet, not at an early one. + const phrase = `The plan. ${"More notes. ".repeat(10)}Since the last fix, the sidebar works`; + expect(scoreMessageMatch(phrase, "fix the sidebar")?.offset).toBe(phrase.indexOf("fix,")); + const apart = `The lakebed app. ${"Notes. ".repeat(30)}Lakebed page perf is slow`; + expect(apart.slice(scoreMessageMatch(apart, "lakebed perf")?.offset)).toMatch(/^Lakebed page/); + // Only where a word counts: not "fix" in "prefix". + const prefix = `fix ${"unrelated words ".repeat(40)}prefix has CI`; + expect(scoreMessageMatch(prefix, "fix ci")?.offset).toBe(0); + // A commit SHA is one word: "cafe" inside one does not match, its start does. + expect(scoreMessageMatch("see commit 7cafe52bb8", "cafe")).toBeNull(); + expect(scoreMessageMatch("see commit 7cafe52bb8", "7cafe52")).toEqual({ + quality: 1, + offset: 11, + }); + expect(scoreMessageMatch("7cafe52bb8, then the cafe", "cafe")?.quality).toBe(1); + // A whole word beats a word prefix: "mode" prefers "mode" over "model". + expect(scoreMessageMatch("model settings", "mode")?.quality).toBe(0.8); + expect(scoreMessageMatch("The model and the mode switch", "mode")).toEqual({ + quality: 1, + offset: 18, + }); + expect(scoreMessageMatch("v12rc build", "rc")?.quality).toBe(1); + expect(scoreMessageMatch("Fix the CLIAdapter state", "ada")?.quality).toBe(0.8); + // Words too short for the index must start a word too. + expect(scoreMessageMatch("Switch off the connection", "switch on")).toBeNull(); + expect(scoreMessageMatch("Upgrade pnpm 512 today", "pnpm 12")).toBeNull(); + expect(scoreMessageMatch("Upgrade to pnpm12 today", "pnpm 12")?.quality).toBe(1); + // A word too short to search only narrows, so typing the next word + // ("nvme s") does not reorder results; written together still counts. + expect(scoreMessageMatch("Fix circle rendering", "fix ci")?.quality).toBe(1); + expect(scoreMessageMatch("Fix CI failures", "fix ci")?.quality).toBe(1.25); + expect(scoreMessageMatch("Upgrade pnpm 123 today", "pnpm 12")?.quality).toBe(1); + expect(scoreMessageMatch("Rename the file to index.ts", ".ts")?.quality).toBe(1); + }); + + it("finds words through case and accents, also after the same letters inside a word", () => { + expect(scoreMessageMatch("This addresses Résumé indexing", "res")).toMatchObject({ + offset: 15, + }); + expect(scoreMessageMatch("Le CAFÉ ouvert", "cafe")).toMatchObject({ quality: 1, offset: 3 }); + // An accented whole word beats a plain prefix earlier on. + expect(scoreMessageMatch("The cafeteria, then the Café", "cafe")).toMatchObject({ + quality: 1, + offset: 24, + }); + expect(scoreMessageMatch("I presume this. Résumé indexing", "resume")).toMatchObject({ + quality: 1, + offset: 16, + }); + expect(scoreMessageMatch("明天部署到生产环境", "部署")).toMatchObject({ offset: 2 }); + // Vietnamese and combining accents fold too. + expect(scoreMessageMatch("Properties: tiếng Việt support", "tie")).toMatchObject({ + offset: 12, + }); + expect(scoreMessageMatch("Properties: tie\u0301ng support", "tie")).toMatchObject({ + offset: 12, + }); + // A letter that grows when lowercased does not shift later offsets. + expect(scoreMessageMatch("İ Enable fts and model", "fts")).toMatchObject({ offset: 9 }); + expect(scoreMessageMatch("İ Enable fts and model", "model")?.quality).toBe(1); + expect(scoreMessageMatch("Trip to İstanbul", "İstanbul")).toEqual({ quality: 1, offset: 8 }); + }); +}); diff --git a/packages/shared/src/paletteSearch.ts b/packages/shared/src/paletteSearch.ts new file mode 100644 index 000000000000..bffe7d22539f --- /dev/null +++ b/packages/shared/src/paletteSearch.ts @@ -0,0 +1,1260 @@ +/** + * Word-aware fuzzy ranking for the command palettes (web, desktop, mobile) and + * the MCP thread search, so every surface puts the same thread first. + * + * A match scores by quality: a whole word beats a word prefix, which beats + * words typed together, a one-typo prefix, and then a mid-word substring. + * Word starts include camelCase, kebab, path, and letter/digit boundaries. + * Thread scores then multiply quality by how recently and often the thread + * was used, within limits. + */ + +import type { OrchestrationV2ThreadOpens } from "@t3tools/contracts"; + +import { WORKTREE_BRANCH_PREFIX } from "./git.ts"; + +const ALPHANUMERIC = /[\p{L}\p{N}]/u; +const LOWERCASE = /\p{Ll}/u; +const UPPERCASE = /\p{Lu}/u; +const DIGIT = /\p{N}/u; +const LETTER = /\p{L}/u; +const COMBINING_MARKS = /\p{M}/gu; +// Emoji and other symbols can be searched for, so they start words too. +const SYMBOL = /\p{So}/u; +const APOSTROPHE = /['’]/; +const CURLY_APOSTROPHES = /[\u2018\u2019]/g; + +interface SearchField { + /** Case- and accent-folded text, one UTF-16 unit per source unit. */ + readonly text: string; + /** 1 where a word starts in `text`. */ + readonly wordStarts: Uint8Array; +} + +export interface SearchRange { + readonly start: number; + readonly end: number; +} + +// Thread titles, branches, and project names repeat across keystrokes; folding +// them once keeps a keystroke to plain string scans. Each keystroke reads every +// field, so the limit must hold all of them or the cache clears every time; a +// field is a few bytes per character. +const fieldCache = new Map(); +const FIELD_CACHE_LIMIT = 100_000; + +function foldCharacter(character: string): string { + // A typed straight apostrophe finds a curly one: iOS keyboards type "’". + return character + .normalize("NFKD") + .replace(COMBINING_MARKS, "") + .toLowerCase() + .replace("ς", "σ") + .replace(/[‘’]/, "'"); +} + +/** + * `text` with case and accents folded one UTF-16 unit at a time, so offsets + * and word starts still read from `text` and highlights need no mapping. A + * letter that folds to more or less than one unit ("ß", "fi") stays as it is. + */ +function foldInPlace(text: string): string { + return text + .replace(/[\u0080-\uffff]/g, (character) => { + const folded = foldCharacter(character); + return folded.length === 1 ? folded : character; + }) + .toLowerCase(); +} + +// Chinese and Japanese text has no spaces, so each ideograph or kana starts a word. +function isCjk(character: string): boolean { + const code = character.codePointAt(0)!; + return ( + (code >= 0x3040 && code <= 0x30ff) || + (code >= 0x3400 && code <= 0x4dbf) || + (code >= 0x4e00 && code <= 0x9fff) || + (code >= 0xf900 && code <= 0xfaff) + ); +} + +// Two Chinese, Japanese, or Korean characters already make a common word. +function hasCjkWord(token: string): boolean { + return Array.from(token).some((character) => { + const code = character.codePointAt(0)!; + return isCjk(character) || (code >= 0xac00 && code <= 0xd7af); + }); +} + +/** + * Whether `character` starts a word after `previous` and `beforePrevious` + * ("" at the start): after a non-letter, at a camelCase or digit boundary, or + * at any Chinese or Japanese character. The one rule for titles and messages. + */ +function isWordStart( + beforePrevious: string, + previous: string, + character: string, + next: string, +): boolean { + // "Julius’s": a possessive or contraction does not start a word. + const afterApostrophe = APOSTROPHE.test(previous) && ALPHANUMERIC.test(beforePrevious); + return ( + (ALPHANUMERIC.test(character) || SYMBOL.test(character)) && + !afterApostrophe && + (previous === "" || + !ALPHANUMERIC.test(previous) || + (LOWERCASE.test(previous) && UPPERCASE.test(character)) || + // The last capital of an acronym starts the next word: "JSONSchema". + (UPPERCASE.test(previous) && UPPERCASE.test(character) && LOWERCASE.test(next)) || + DIGIT.test(previous) !== DIGIT.test(character) || + isCjk(character)) + ); +} + +function foldSearchField(source: string): SearchField { + const wordStarts = new Uint8Array(source.length); + const characters = Array.from(source); + let index = 0; + for (const [position, character] of characters.entries()) { + const previous = characters[position - 1] ?? ""; + const next = characters[position + 1] ?? ""; + if (isWordStart(characters[position - 2] ?? "", previous, character, next)) { + wordStarts[index] = 1; + } + index += character.length; + } + return { text: foldInPlace(source), wordStarts }; +} + +function prepareSearchField(source: string): SearchField { + const cached = fieldCache.get(source); + if (cached) return cached; + // Oldest first out. A read does not re-insert: that would cost more than the + // fold it saves, and the limit holds far more fields than a keystroke reads. + if (fieldCache.size >= FIELD_CACHE_LIMIT) { + fieldCache.delete(fieldCache.keys().next().value!); + } + const field = foldSearchField(source); + fieldCache.set(source, field); + return field; +} + +// Quotes around a pasted query: "fix sidebar". +const WRAPPING_QUOTES = /^\s*["'`“”‘’]+|["'`“”‘’]+\s*$/g; + +/** Folded, whitespace-collapsed query; empty when there is nothing to match. */ +export function foldSearchQuery(query: string): string { + return foldInPlace(query.replace(WRAPPING_QUOTES, "").normalize("NFC")) + .split(/\s+/) + .filter((token) => token.length > 0) + .join(" "); +} + +function wordEnd(field: SearchField, index: number): number { + let end = index + 1; + while (end < field.text.length && !field.wordStarts[end] && ALPHANUMERIC.test(field.text[end]!)) { + end++; + } + return end; +} + +// One substitution, insertion, deletion, or swap of neighbours; with +// `swapOnly`, only a swap. +function withinOneEdit(left: string, right: string, swapOnly: boolean): boolean { + if (Math.abs(left.length - right.length) > (swapOnly ? 0 : 1)) return false; + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) index++; + const restLeft = left.slice(index); + const restRight = right.slice(index); + const swapped = + restLeft.length > 1 && + restLeft[0] === restRight[1] && + restLeft[1] === restRight[0] && + restLeft.slice(2) === restRight.slice(2); + if (swapOnly) return swapped; + return ( + swapped || + restLeft === restRight || + restLeft.slice(1) === restRight.slice(1) || + restLeft.slice(1) === restRight || + restLeft === restRight.slice(1) + ); +} + +const WHOLE_WORD = 1; +// The bonus for a field equal to the whole query lifts a whole word to this. +const WHOLE_NAME = 1.5; +const MID_WORD = 0.3; +const TYPO = 0.45; +// Whole words typed together are exact, so they rank with typos and above +// mid-word hits, though below a match at a word start. +const JOINED_WORDS = 0.6; + +interface TokenMatch extends SearchRange { + readonly score: number; + /** Separate ranges, for words typed together. */ + readonly letters?: ReadonlyArray; +} + +/** + * Best match of one folded query token in a field. Tokens shorter than four + * characters only match at a word start ("del" should not find "model"), and + * longer ones only inside a word where `midWord` allows it. With + * `typos`, a three-character token may swap a pair and a longer one may carry + * one typo in a word prefix that keeps its first letter. Numbers never match + * with a typo: 22889 is not pull request 22189. In the primary field (the + * name), the token may also be whole words typed together (see + * `matchJoinedWords`). + */ +function matchToken( + field: SearchField, + token: string, + typos: boolean, + isPrimary: boolean, + midWord: boolean, +): TokenMatch | null { + if (token.length === 0) return null; + let best: TokenMatch | null = null; + const ownBoundary = !ALPHANUMERIC.test(token[0]!) && !SYMBOL.test(token[0]!); + for ( + let index = field.text.indexOf(token); + index !== -1; + index = field.text.indexOf(token, index + 1) + ) { + // A token that opens with punctuation (".ts") brings its own boundary, as in messages. + if (field.wordStarts[index] || ownBoundary) { + // Whole when the match ends where a word ends: "t3c" is a prefix of + // "t3code", ".ts" whole in "index.ts" but a prefix in "parser.tsx". + const end = index + token.length; + const whole = + end >= field.text.length || + field.wordStarts[end] === 1 || + !ALPHANUMERIC.test(field.text[end]!); + let runEnd = end; + while (runEnd < field.text.length && ALPHANUMERIC.test(field.text[runEnd]!)) runEnd++; + const score = whole ? WHOLE_WORD : 0.7 + (0.2 * token.length) / (runEnd - index); + if (best === null || score > best.score) { + best = { score, start: index, end: index + token.length }; + } + if (score === WHOLE_WORD) return best; + } else if (midWord && token.length >= 4 && best === null) { + best = { score: MID_WORD, start: index, end: index + token.length }; + } + } + // Joined words are exact, so they count without the typo pass: a strong + // match elsewhere must not hide "VS Code" for "vscode". + if ((best === null || best.score <= MID_WORD) && isPrimary) { + const joined = matchJoinedWords(field, token); + if (joined) return joined; + } + if ((best !== null && best.score > MID_WORD) || !typos || token.length < 3) return best; + // Numbers skip typos: #22889 is not #22189. Words with digits ("t3code") do not. + if (LETTER.test(token)) { + for (let index = 0; index < field.text.length; index++) { + if (!field.wordStarts[index]) continue; + // People rarely mistype the first letter, so a typo keeps it, unless it + // swaps the first two of a longer word ("ing" is not "nig…"). + const first = field.text[index]; + const firstLetterKept = + first === token[0] || + (token.length > 3 && first === token[1] && field.text[index + 1] === token[0]); + if (!firstLetterKept) continue; + // The whole run, across camelCase joins: "githbu" is "GitHub". + let runEnd = index + 1; + while (runEnd < field.text.length && ALPHANUMERIC.test(field.text[runEnd]!)) runEnd++; + const word = field.text.slice(index, runEnd); + // Short tokens forgive a swapped pair or one missing letter ("srv" for + // "serv…"); any other edit of a three- or four-letter word matches too + // much ("cron" would find every "con…"). + const short = token.length <= 4; + // The same length first, so "githbu" highlights all of "GitHub". + const lengths = short + ? [token.length, token.length + 1] + : [token.length, token.length - 1, token.length + 1]; + for (const length of lengths) { + if (length > word.length) continue; + const prefix = word.slice(0, length); + if (withinOneEdit(token, prefix, short && length === token.length)) { + return { score: TYPO, start: index, end: index + length }; + } + } + } + } + return best; +} + +/** + * Whole words typed together: "vscode" is "VS Code", "fixsid" is "Fix + * sid(ebar)". Every part is a whole word but the last. Short words before a + * short prefix are a coincidence inside a title ("inth" is not "in th(read)"), + * but not at its start ("t3co" is "T3 Co(de)"), before three letters + * ("gomod"), or a whole word ("vscode"). + */ +function matchJoinedWords(field: SearchField, token: string): TokenMatch | null { + const firstWord = field.wordStarts.indexOf(1); + for (let start = 0; start < field.text.length; start++) { + if (!field.wordStarts[start] || field.text[start] !== token[0]) continue; + const runs: SearchRange[] = []; + let at = start; + let rest = token; + while (rest.length > 0 && at < field.text.length) { + const end = wordEnd(field, at); + if (rest.length <= end - at && field.text.startsWith(rest, at)) { + runs.push({ start: at, end: at + rest.length }); + rest = ""; + } else if (rest.startsWith(field.text.slice(at, end))) { + runs.push({ start: at, end }); + rest = rest.slice(end - at); + at = end; + while (at < field.text.length && !field.wordStarts[at]) at++; + } else { + break; + } + } + const last = runs.at(-1); + if (rest.length > 0 || last === undefined || runs.length < 2) continue; + if ( + last.end === wordEnd(field, last.start) || + last.end - last.start >= 3 || + start === firstWord || + runs.slice(0, -1).every((run) => run.end - run.start >= 3) + ) { + return { score: JOINED_WORDS, start, end: last.end, letters: runs }; + } + } + return null; +} + +interface WeightedSearchField { + readonly value: string; + readonly weight: number; + /** Only a whole-word match counts, as for a short query against keywords. */ + readonly wholeWords?: boolean; + /** + * Only a match at a word start counts, without typos: hidden keywords that + * matched loosely would list results the user cannot see why for. + */ + readonly keyword?: boolean; + /** + * Where the thread lives (project, environment): it helps a match, but a + * word start there does not make the match strong, so it never turns off + * joined words or typos for the titles. + */ + readonly context?: boolean; + /** + * A token of four or more letters may match inside a word here. Only thread + * titles allow it: inside a command name or a branch it reads as junk + * ("arch" in "Search"). + */ + readonly midWord?: boolean; +} + +interface SearchFieldsMatch { + /** Roughly 0.3 (inside a word) to 1.5 (exact title). */ + readonly quality: number; + /** Every token matched at a word start, without a typo. */ + readonly strong: boolean; + /** Source ranges matched in the first field, for highlighting. */ + readonly primaryRanges: ReadonlyArray; +} + +const FILLER_WORDS = new Set(["a", "an", "and", "for", "in", "of", "on", "the", "to", "with"]); + +// Sorted, with overlapping ranges joined. +function mergeRanges(ranges: ReadonlyArray): SearchRange[] { + const merged: SearchRange[] = []; + for (const range of [...ranges].sort((left, right) => left.start - right.start)) { + const last = merged.at(-1); + if (last && range.start <= last.end) { + merged[merged.length - 1] = { start: last.start, end: Math.max(last.end, range.end) }; + } else { + merged.push({ start: range.start, end: range.end }); + } + } + return merged; +} + +/** + * Every query token must match some field. Quality is the mean of each token's + * best weighted match, plus a bonus when the first field holds the whole query + * as a phrase or equals it. + */ +function matchSearchFields( + normalizedQuery: string, + fields: ReadonlyArray, + options?: { readonly typos?: boolean }, +): SearchFieldsMatch | null { + if (normalizedQuery.length === 0) return null; + const prepared = fields + .filter((field) => field.value.length > 0) + .map((field) => ({ + field: prepareSearchField(field.value), + weight: field.weight, + wholeWords: field.wholeWords ?? false, + keyword: field.keyword ?? false, + context: field.context ?? false, + allowMidWord: field.midWord ?? false, + })); + const primary = fields[0]?.value ? prepared[0]?.field : undefined; + // An empty token would match everywhere, and loop forever in `matchToken`. + const tokens = normalizedQuery.split(" ").filter((token) => token.length > 0); + if (tokens.length === 0) return null; + // A phrase-style query ("fix the sidebar") carries filler words a name may + // leave out; they count where they match but are not required. + const fillerOptional = tokens.some((token) => !FILLER_WORDS.has(token)); + const primaryRanges: SearchRange[] = []; + let total = 0; + let counted = 0; + let strong = true; + for (const token of tokens) { + let best = 0; + let bestPrimary: TokenMatch | null = null; + let atWordStart = false; + for (const { field, weight, wholeWords, keyword, context, allowMidWord } of prepared) { + if (best >= weight && field !== primary && atWordStart) continue; + const typos = (options?.typos ?? false) && !keyword; + // Joined words only count in the name. + const match = matchToken(field, token, typos, field === primary, allowMidWord); + if (match === null || match.score < (wholeWords ? WHOLE_WORD : keyword ? 0.7 : 0)) continue; + if (field === primary) bestPrimary = match; + if (match.score >= 0.7 && !context) atWordStart = true; + if (match.score * weight > best) best = match.score * weight; + } + if (best === 0) { + if (fillerOptional && FILLER_WORDS.has(token)) continue; + return null; + } + strong &&= atWordStart; + if (bestPrimary) primaryRanges.push(...(bestPrimary.letters ?? [bestPrimary])); + total += best; + counted += 1; + } + let quality = total / counted; + if (primary) { + if (primary.text === normalizedQuery) quality += WHOLE_NAME - WHOLE_WORD; + else if (tokens.length > 1 && primary.text.includes(normalizedQuery)) quality += 0.25; + } + return { + quality, + strong, + primaryRanges: mergeRanges(primaryRanges), + }; +} + +// A two-letter Latin substring of a message is noise rather than a match. +const MESSAGE_TOKEN_MIN_LENGTH = 3; + +/** + * Message search tokens, lowercased and NFC-composed but not stripped of + * accents: the server's index folds Latin accents itself, and stripping marks + * would break Korean and Japanese text. Every `long` token must appear in a + * message; `short` ones only narrow those hits ("pnpm 12"). A query with no + * long token searches no messages. Two CJK characters count as long. + */ +export function messageSearchTokens(query: string): { + readonly long: ReadonlyArray; + readonly short: ReadonlyArray; + /** Every token, in the order typed. */ + readonly words: ReadonlyArray; +} { + const tokens = query + .replace(WRAPPING_QUOTES, "") + // The index stores apostrophes straight, as `indexedMessageText` writes them. + .replace(CURLY_APOSTROPHES, "'") + .normalize("NFC") + .toLowerCase() + .split(/\s+/) + .filter((token) => token.length > 0); + const searchable = (token: string) => { + const length = Array.from(token).length; + return length >= MESSAGE_TOKEN_MIN_LENGTH || (length === 2 && hasCjkWord(token)); + }; + return { + long: tokens.filter(searchable), + short: tokens.filter((token) => !searchable(token)), + words: tokens, + }; +} + +/** + * Matches the query's words side by side as whole words, as typed, whatever + * whitespace is between them and ignoring case; null for a single word. + */ +export function messagePhrase(query: string): RegExp | null { + const { words } = messageSearchTokens(query); + return words.length > 1 + ? new RegExp( + `(? word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("\\s+")}(?![\\p{L}\\p{N}])`, + "iu", + ) + : null; +} + +// Words written together, as typed, rank above the same words apart, as in +// titles; words near each other rank above words far apart. +const MESSAGE_PHRASE_BONUS = 0.25; +const MESSAGE_NEAR_BONUS = 0.1; +// About what a result row shows. +const MESSAGE_NEAR_SPAN = 60; + +const NON_ASCII = /[\u0080-\uffff]/; +// A message token shorter than this must start a word: "fts" is not in "drafts". +const MESSAGE_WORD_START_LENGTH = 4; +const MESSAGE_PREFIX = 0.8; + +function startsWordAt(text: string, index: number): boolean { + const character = text[index]!; + // A token that opens with punctuation (".ts", "#22889") brings its own boundary. + if (!ALPHANUMERIC.test(character) && !SYMBOL.test(character)) return true; + return isWordStart( + text[index - 2] ?? "", + text[index - 1] ?? "", + character, + text[index + 1] ?? "", + ); +} + +// Whether a word ends right before `end` in `text`. +function endsWordAt(text: string, end: number): boolean { + return end >= text.length || !ALPHANUMERIC.test(text[end]!) || startsWordAt(text, end); +} + +// A commit SHA or UUID part: seven or more hex characters with a digit. +const HEX_ID = /^(?=[a-f]*\d)[0-9a-f]{7,}$/i; + +// Where `token` best sits in `folded`, a fold of `text`: every place it is a +// whole word, else a word start, else inside a word. Hits past the start of a +// hex ID do not count ("cafe" in "7cafe52bb8"), though a pasted SHA prefix +// does. +function findMessageToken( + text: string, + folded: string, + token: string, +): { readonly at: ReadonlyArray; readonly quality: number } | null { + let quality = 0; + let at: number[] = []; + // The letters-and-digits run around the hit, kept for later hits in the + // same run: rescanning it per hit is quadratic in a long run ("0000" in a + // hex dump). + let run = { start: 0, end: -1, hex: false }; + for (let index = folded.indexOf(token); index !== -1; index = folded.indexOf(token, index + 1)) { + if (index + token.length > run.end) { + let start = index; + while (start > 0 && ALPHANUMERIC.test(text[start - 1]!)) start--; + let end = index + token.length; + while (end < text.length && ALPHANUMERIC.test(text[end]!)) end++; + run = { start, end, hex: HEX_ID.test(text.slice(start, end)) }; + } + if (run.hex && run.start < index) continue; + const found = !startsWordAt(text, index) + ? MID_WORD + : endsWordAt(text, index + token.length) + ? 1 + : MESSAGE_PREFIX; + if (found > quality) { + quality = found; + at = [index]; + } else if (found === quality) { + at.push(index); + } + } + return at.length === 0 ? null : { at, quality }; +} + +export interface MessageMatch { + /** + * 1 when every searched word is a whole word, MESSAGE_PREFIX when one only + * starts a word ("mode" in "model"), MID_WORD when one only appears inside + * words, plus MESSAGE_PHRASE_BONUS when the words are written together as + * typed, else MESSAGE_NEAR_BONUS when they sit within a row's width. + */ + readonly quality: number; + /** Where a snippet should start to show the match. */ + readonly offset: number; +} + +/** + * Grades messages against a query, so message hits rank like titles: null + * when a token is missing or a short one never starts a word ("fts" in + * "drafts"). Case and accents are ignored, as in the server's index. The + * query is prepared once, since the server grades thousands of hits for it. + */ +export function messageMatcher(query: string): (text: string) => MessageMatch | null { + const { long, short, words } = messageSearchTokens(query); + const tokens = [...long, ...short].map((token) => ({ + token, + // Marks go from the folded token: lowercasing "İ" adds one. + folded: foldInPlace(token).replace(COMBINING_MARKS, ""), + nonAscii: NON_ASCII.test(token), + mustStartWord: Array.from(token).length < MESSAGE_WORD_START_LENGTH, + // Too short to search, so it only narrows: grading by it would reorder + // results on each keystroke while the next word is typed ("nvme s"). + grades: long.includes(token), + })); + const phrase = messagePhrase(query); + // Accents typed or left out still read as written together: "cafe open" + // in "Café open". + const foldedPhrase = messagePhrase( + words.map((word) => foldInPlace(word).replace(COMBINING_MARKS, "")).join(" "), + ); + const queryNonAscii = NON_ASCII.test(query); + return (text) => { + const lower = text.toLowerCase(); + // "İ" lowercases to two units, which would shift every offset after it. + const plain = lower.length === text.length ? lower : foldInPlace(text); + let folded: string | null = null; + let quality = 1; + const positions: Array> = []; + for (const { token, folded: foldedToken, nonAscii, mustStartWord, grades } of tokens) { + let found = findMessageToken(text, plain, token); + // Accents typed or left out: "cafe" finds "Café", also after "cafeteria". + if (found?.quality !== 1 && foldedToken.length > 0 && (nonAscii || NON_ASCII.test(text))) { + found = findMessageToken(text, (folded ??= foldInPlace(text)), foldedToken); + } + if (found === null) return null; + if (found.quality === MID_WORD && mustStartWord) return null; + if (grades) quality = Math.min(quality, found.quality); + positions.push(found.at); + } + // Written together as typed: the snippet starts there. Apostrophes are + // straight in the query, and straightening keeps offsets. + const straight = (value: string) => value.replace(CURLY_APOSTROPHES, "'"); + const together = + phrase?.exec(straight(plain)) ?? + (queryNonAscii || NON_ASCII.test(text) + ? foldedPhrase?.exec(straight((folded ??= foldInPlace(text)))) + : null); + if (together) return { quality: quality + MESSAGE_PHRASE_BONUS, offset: together.index }; + // Else the shortest stretch that holds every word: the snippet starts + // there, so it shows them all, and words close together rank above words + // far apart ("lakebed perf"). + const near = shortestSpan(positions); + return { + quality: + long.length > 1 && near.length <= MESSAGE_NEAR_SPAN + ? quality + MESSAGE_NEAR_BONUS + : quality, + offset: near.start, + }; + }; +} + +// The shortest stretch of text holding one place of each word, from each +// word's places in order. +function shortestSpan(places: ReadonlyArray>): { + readonly start: number; + readonly length: number; +} { + const hits = places + .flatMap((at, word) => at.map((index) => ({ index, word }))) + .sort((left, right) => left.index - right.index); + const counts = places.map(() => 0); + let covered = 0; + let best = { start: hits[0]?.index ?? 0, length: Number.POSITIVE_INFINITY }; + let left = 0; + for (const hit of hits) { + counts[hit.word] = (counts[hit.word] ?? 0) + 1; + if (counts[hit.word] === 1) covered++; + while (covered === places.length) { + const first = hits[left]!; + if (hit.index - first.index < best.length) { + best = { start: first.index, length: hit.index - first.index }; + } + counts[first.word] = (counts[first.word] ?? 1) - 1; + if (counts[first.word] === 0) covered--; + left++; + } + } + return best; +} + +/** + * Ranges of `text` that contain a long message search token, for message + * snippets, ignoring case and accents. Shorter tokens would light up half the + * snippet, and a short long token only counts where it starts a word. + * Snippets vary per query, so they skip the field cache. + */ +export function findSearchTokenRanges(text: string, query: string): SearchRange[] { + const tokens = new Map( + messageSearchTokens(query) + .long.map( + (token) => + [foldSearchQuery(token), Array.from(token).length < MESSAGE_WORD_START_LENGTH] as const, + ) + // Some tokens fold to nothing ("´´´"), which would match everywhere. + .filter(([token]) => token.length > 0), + ); + if (tokens.size === 0) return []; + const field = foldSearchField(text); + const ranges: SearchRange[] = []; + for (const [token, needsWordStart] of tokens) { + // A token that opens with punctuation brings its own boundary, as in scoring. + const ownBoundary = !ALPHANUMERIC.test(token[0]!) && !SYMBOL.test(token[0]!); + for (let index = field.text.indexOf(token); index !== -1;) { + if (needsWordStart && !ownBoundary && !field.wordStarts[index]) { + index = field.text.indexOf(token, index + 1); + continue; + } + ranges.push({ start: index, end: index + token.length }); + index = field.text.indexOf(token, index + token.length); + } + } + return mergeRanges(ranges); +} + +export interface SearchTextPart { + readonly text: string; + readonly highlighted: boolean; + readonly start: number; +} + +/** `text` cut into plain and highlighted parts at sorted, non-overlapping `ranges`. */ +export function splitSearchText( + text: string, + ranges: ReadonlyArray, +): SearchTextPart[] { + const parts: SearchTextPart[] = []; + let cursor = 0; + for (const range of ranges) { + if (range.start > cursor) { + parts.push({ text: text.slice(cursor, range.start), highlighted: false, start: cursor }); + } + parts.push({ text: text.slice(range.start, range.end), highlighted: true, start: range.start }); + cursor = range.end; + } + if (cursor < text.length || parts.length === 0) { + parts.push({ text: text.slice(cursor), highlighted: false, start: cursor }); + } + return parts; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +/** Both activity recency and open counts halve every two weeks. */ +export const SEARCH_RECENCY_HALF_LIFE_MS = 14 * DAY_MS; +/** + * Opens closer together than this count once, so flipping between threads is + * not frequency. Clients skip sending them. + */ +export const THREAD_OPEN_DEDUPE_MS = 10 * 60 * 1000; +const THREAD_SEARCH_PICK_LIMIT = 3; +/** Picked queries are stored this long; clients send no more. */ +const THREAD_SEARCH_PICK_MAX_LENGTH = 32; + +/** + * `query` as a pick is stored and compared: folded, then cut to the stored + * length without splitting an emoji in two. Clients and the server both use + * it, so a long pick matches its query again. + */ +export function pickedQuery(query: string): string { + const cut = foldSearchQuery(query).slice(0, THREAD_SEARCH_PICK_MAX_LENGTH); + return (/[\ud800-\udbff]$/.test(cut) ? cut.slice(0, -1) : cut).trim(); +} + +function decay(ageMs: number): number { + return 2 ** (-Math.max(0, ageMs) / SEARCH_RECENCY_HALF_LIFE_MS); +} + +/** Server-stored open history; see `OrchestrationV2ThreadOpens`. */ +export type ThreadOpenStats = OrchestrationV2ThreadOpens; + +/** + * Next open stats after an open at `at`. `query` is the palette query that + * picked the thread, when it was opened from search. + */ +export function recordThreadOpen( + previous: ThreadOpenStats | null, + at: number, + query: string | undefined, +): ThreadOpenStats { + const pick = pickedQuery(query ?? ""); + // Picks that no longer count are not kept or synced. + const earlier = (previous?.picks ?? []).filter((entry) => at - entry.at <= PICK_WINDOW_MS); + const picks = pick + ? [{ query: pick, at }, ...earlier.filter((entry) => entry.query !== pick)].slice( + 0, + THREAD_SEARCH_PICK_LIMIT, + ) + : earlier; + if (previous !== null && at - previous.openedAt < THREAD_OPEN_DEDUPE_MS) { + return { ...previous, picks }; + } + const score = previous === null ? 1 : previous.score * decay(at - previous.openedAt) + 1; + return { openedAt: at, score, picks }; +} + +export interface ThreadSearchSubject { + /** Matched only by the whole query, like references. */ + readonly id?: string; + readonly title: string; + readonly branch?: string | null; + readonly projectTitle?: string | null; + /** Only set with two or more environments; it tells same-named threads apart. */ + readonly environmentLabel?: string | null; + readonly pullRequests?: ReadonlyArray<{ + readonly number: number; + readonly title?: string | null; + /** Values such as URLs, matched only by the whole query. */ + readonly references?: ReadonlyArray; + }>; + /** Latest user activity, in epoch ms. */ + readonly activityAt: number | null; + readonly opens?: ThreadOpenStats | null; +} + +// Use reorders matches of about the same quality but never turns a mid-word +// hit on a busy thread into a better result than a whole word on a quiet one. +const USE_BOOST_LIMIT = 2.5; + +/** + * How much use lifts a thread: 1 for a stale, never-opened thread, about 2 for + * one active today, and up to 2.5 for threads opened often. + */ +function threadUsageBoost(thread: ThreadSearchSubject, now: number): number { + const recency = thread.activityAt === null ? 0 : decay(now - thread.activityAt); + const opens = thread.opens ? thread.opens.score * decay(now - thread.opens.openedAt) : 0; + return Math.min(USE_BOOST_LIMIT, 1 + recency + 0.5 * Math.log2(1 + opens)); +} + +const REFERENCE_PREFIX_MIN = 8; + +// IDs and URLs split into many word starts, so matching them as fields would +// let a two-letter query hit every thread. Only a long piece of one matches: a +// prefix, or, when it holds a digit like an ID or pull request number, any +// part ("t3code#22889", a URL without its scheme). Both are ASCII, so +// lowercasing is enough. +function matchReference( + normalizedQuery: string, + thread: ThreadSearchSubject, +): SearchFieldsMatch | null { + if (normalizedQuery.length < REFERENCE_PREFIX_MIN) return null; + const references = [ + ...(thread.id ? [thread.id] : []), + ...(thread.pullRequests ?? []).flatMap((pullRequest) => pullRequest.references ?? []), + ]; + const anywhere = DIGIT.test(normalizedQuery); + // A number is never cut short: ".../pull/2288" is not pull request 22889. + const notCut = (text: string, end: number) => !DIGIT.test(text[end] ?? ""); + return references.some((reference) => { + const folded = reference.toLowerCase(); + const at = folded.indexOf(normalizedQuery); + // A pasted link may go on past the reference: ".../pull/22889/files". + return anywhere + ? (at !== -1 && notCut(folded, at + normalizedQuery.length)) || + (normalizedQuery.startsWith(folded) && notCut(normalizedQuery, folded.length)) + : at === 0; + }) + ? { quality: 0.6, strong: true, primaryRanges: [] } + : null; +} + +// How long a pick lasts. While it does, typing the query again (or the start of +// it) puts the thread first among its group's word matches, the exact query's +// pick and then the latest first. A pick never moves the group itself, so a +// thread picked once for "new" does not pass the command of that name. It runs +// from the pick, so an old pick lets go even if the thread is opened in other +// ways. +const PICK_WINDOW_MS = 21 * DAY_MS; +// The start of a picked query counts from three letters: "n" is too little to +// go on. +const PICK_PREFIX_MIN_LENGTH = 3; + +/** The latest pick of the thread for this query, while it lasts. */ +function activePick( + thread: ThreadSearchSubject, + normalizedQuery: string, + now: number, +): ThreadOpenStats["picks"][number] | undefined { + // Stored picks are cut to a length, so compare the query cut the same way. + const query = pickedQuery(normalizedQuery); + const prefix = query.length >= PICK_PREFIX_MIN_LENGTH; + // The latest exact pick, else the latest pick the query starts. + let exact: ThreadOpenStats["picks"][number] | undefined; + let latest: ThreadOpenStats["picks"][number] | undefined; + for (const pick of thread.opens?.picks ?? []) { + if (now - pick.at > PICK_WINDOW_MS) continue; + if (pick.query === query && (exact === undefined || pick.at > exact.at)) exact = pick; + const matches = pick.query === query || (prefix && pick.query.startsWith(query)); + if (matches && (latest === undefined || pick.at > latest.at)) latest = pick; + } + return exact ?? latest; +} + +export interface ThreadSearchMatch { + /** Match quality times use; what results sort by. */ + readonly score: number; + /** Match quality alone; see `rankSearchGroups`. */ + readonly quality: number; + /** Every token matched at a word start; see `rankSearchGroups`. */ + readonly strong: boolean; + /** Matched ranges in the title. */ + readonly ranges: ReadonlyArray; + /** + * Why the thread matched when its title shows no match, for the result row. + * Built on demand, so only the rows shown pay for it. + */ + readonly explain?: () => SearchMatchHint | undefined; + /** When the thread was picked for this query, while that pick lasts; see `rankSearchGroups`. */ + readonly pickedAt?: number; + /** Picked for exactly this query, before newer picks for longer ones; see `rankSearchGroups`. */ + readonly pickedExactly?: boolean; + /** Below the whole-name class; see `scoreThreadSearch`. */ + readonly groupQuality: number; +} + +export interface SearchMatchHint { + readonly label: string; + readonly text: string; + readonly ranges: ReadonlyArray; +} + +// Most threads' branches start with the app's own worktree prefix ("t3/", and +// "t3code/" before it), so "t3 connect" would match nearly every thread. +// Only before more text, so typing "t3/" does not empty the list for a keystroke. +const WORKTREE_BRANCH_PREFIX_PATTERN = new RegExp(`^(?:${WORKTREE_BRANCH_PREFIX}|t3code)/(?=\\S)`); + +function searchableBranch(branch: string | null | undefined): string | null { + return branch ? branch.replace(WORKTREE_BRANCH_PREFIX_PATTERN, "") : null; +} + +/** + * The field that explains a match whose title shows none: the first that holds + * the whole query, else, past the words the project or environment holds, the + * first that holds the rest ("lakebed perf" in project "lakebed" with the PR + * title "perf: …"). + */ +function matchHint( + normalizedQuery: string, + thread: ThreadSearchSubject, + typos: boolean, +): SearchMatchHint | undefined { + const candidates: Array<{ readonly label: string; readonly text: string | null | undefined }> = [ + ...(thread.pullRequests ?? []).map((pullRequest) => ({ + label: "PR", + text: `#${pullRequest.number}${pullRequest.title ? ` ${pullRequest.title}` : ""}`, + })), + { label: "Branch", text: searchableBranch(thread.branch) }, + { label: "Project", text: thread.projectTitle }, + { label: "Environment", text: thread.environmentLabel }, + ]; + const holds = (text: string, query: string) => + matchSearchFields(query, [{ value: text, weight: 1 }], { typos })?.primaryRanges; + const hintFor = (query: string) => { + for (const { label, text } of candidates) { + const ranges = text ? holds(text, query) : undefined; + if (text && ranges) return { label, text, ranges }; + } + return undefined; + }; + const whole = hintFor(normalizedQuery); + if (whole) return whole; + const contexts = [thread.projectTitle, thread.environmentLabel].filter( + (text): text is string => !!text, + ); + const rest = normalizedQuery + .split(" ") + .filter((token) => !contexts.some((text) => holds(text, token))) + .join(" "); + return rest && rest !== normalizedQuery ? hintFor(rest) : undefined; +} + +/** + * Scores a thread for a normalized query, or null when it does not match. + * Titles and pull request numbers count fully, pull request titles and + * branches less, project and environment names least. + */ +export function scoreThreadSearch( + normalizedQuery: string, + thread: ThreadSearchSubject, + now: number, + options?: { + readonly typos?: boolean; + /** + * Palette picks; off where a pick would show up unexplained, such as a + * sidebar filter or an agent's search. + */ + readonly picks?: boolean; + }, +): ThreadSearchMatch | null { + const fields: WeightedSearchField[] = [{ value: thread.title, weight: 1, midWord: true }]; + for (const pullRequest of thread.pullRequests ?? []) { + fields.push({ value: `#${pullRequest.number}`, weight: 1 }); + if (pullRequest.title) fields.push({ value: pullRequest.title, weight: 0.5 }); + } + const branch = searchableBranch(thread.branch); + if (branch) fields.push({ value: branch, weight: 0.6 }); + if (thread.projectTitle) { + fields.push({ value: thread.projectTitle, weight: 0.25, context: true }); + } + if (thread.environmentLabel) { + fields.push({ value: thread.environmentLabel, weight: 0.25, context: true }); + } + // "#22889", "pr 22889", and a pasted "t3/branch" search without their + // prefixes, as the branch field is stored without the app's worktree prefix. + const fieldQuery = normalizedQuery + .replace(/^(?:pr|pull request|pull)\s*(?=#?\d)/, "") + .replace(/^#\s*/, "") + .replace(WORKTREE_BRANCH_PREFIX_PATTERN, ""); + const fieldMatch = matchSearchFields(fieldQuery, fields, options); + const referenceMatch = fieldMatch ? null : matchReference(normalizedQuery, thread); + const match = fieldMatch ?? referenceMatch; + if (match === null) return null; + // A row whose title shows no match must say why. With several words, one + // that cannot (its words spread over unrelated fields) is a coincidence and + // is left out; a single word always sits in the field that matched it, so + // its hint waits until the row is shown. + const typos = options?.typos ?? false; + const titleShowsMatch = match.primaryRanges.length > 0; + const checkHint = !titleShowsMatch && referenceMatch === null && fieldQuery.includes(" "); + const checkedHint = checkHint ? matchHint(fieldQuery, thread, typos) : undefined; + if (checkHint && checkedHint === undefined) return null; + const explain = titleShowsMatch + ? undefined + : (): SearchMatchHint | undefined => + referenceMatch + ? { label: "ID or link", text: normalizedQuery, ranges: [] } + : (checkedHint ?? matchHint(fieldQuery, thread, typos)); + const pick = options?.picks === false ? undefined : activePick(thread, normalizedQuery, now); + return { + score: match.quality * threadUsageBoost(thread, now), + quality: match.quality, + // A thread's whole title never lifts its group over a command or project + // of that name: every new thread is titled "New thread" for a while. + groupQuality: Math.min(match.quality, WHOLE_NAME - 0.01), + strong: match.strong, + ranges: match.primaryRanges, + ...(explain ? { explain } : {}), + ...(pick ? { pickedAt: pick.at } : {}), + ...(pick?.query === pickedQuery(normalizedQuery) ? { pickedExactly: true } : {}), + }; +} + +/** When the user last used a thread: their last message or their last open. */ +export function threadRecentUse(thread: ThreadSearchSubject): number { + return Math.max(thread.activityAt ?? 0, thread.opens?.openedAt ?? 0); +} + +/** + * How strongly a thread's messages match among matches of one quality: a + * thread that keeps using the word outweighs a recent passing mention, as + * hits count for more than recency or use (21 hits about 5.5, one hit 2). + */ +function messageMatchWeight(hits: number): number { + return 1 + Math.log2(1 + hits); +} + +/** + * Orders message matches from the server, from any number of environments: + * word matches before matches inside words, then threads picked for the + * query, then match quality, then hit count times use, then the newest + * message. With `withoutTitleMatches`, threads that match by title are left + * out: a palette lists them above, so they must not use up message rows. + */ +export function rankMessageMatches< + T extends { + readonly quality?: number; + readonly hits?: number; + readonly messageCreatedAt?: string | null; + }, +>( + matches: ReadonlyArray, + subjectOf: (match: T) => ThreadSearchSubject | undefined, + normalizedQuery: string, + now: number, + options?: { readonly picks?: boolean; readonly withoutTitleMatches?: boolean }, +): T[] { + return matches + .flatMap((match) => { + const subject = subjectOf(match); + if (!subject) return []; + if ( + options?.withoutTitleMatches && + scoreThreadSearch(normalizedQuery, subject, now, { picks: false }) !== null + ) { + return []; + } + // Older servers send no quality; their matches count as words. + const quality = match.quality ?? 1; + return [ + { + match, + quality, + pickedAt: + options?.picks === false ? 0 : (activePick(subject, normalizedQuery, now)?.at ?? 0), + score: messageMatchWeight(match.hits ?? 1) * threadUsageBoost(subject, now), + }, + ]; + }) + .sort( + (left, right) => + // Word matches first; among them a pick, then the closer match. + Number(right.quality >= MESSAGE_PREFIX) - Number(left.quality >= MESSAGE_PREFIX) || + right.pickedAt - left.pickedAt || + right.quality - left.quality || + right.score - left.score || + (right.match.messageCreatedAt ?? "").localeCompare(left.match.messageCreatedAt ?? ""), + ) + .map(({ match }) => match); +} + +/** Palette limits shared by web and mobile. */ +export const PALETTE_THREAD_LIMIT = 20; +export const PALETTE_MESSAGE_LIMIT = 10; +/** Next to matching threads, other groups show only their best few rows. */ +export const PALETTE_SECONDARY_LIMIT = 3; + +// The whole name typed (1.5 and up), every token at a word start of a main +// field (0.7 and up), a typo in a main field (0.4 and up), or weaker. Whole +// word against prefix is too fine a line for group order: "compact" must not +// lift a setting named "Compact…" over the thread "Compaction…". But the +// project named "lakebed" leads for "lakebed", and "keybindngs" is the setting +// of that name, not a command that lists it as a keyword. +function matchClass(quality: number): number { + return quality >= WHOLE_NAME ? 3 : quality >= 0.7 ? 2 : quality >= 0.4 ? 1 : 0; +} + +export interface RankedSearchItem { + readonly item: T; + readonly match: M; +} + +/** + * Ranks grouped items: within a group, word matches before weaker ones, + * results picked for exactly the query first among them (the latest pick + * first), then by match class, then results picked for a query that starts + * with this one, then best score. Groups with no match are dropped. The rest order by the class of + * their best match (the whole name, word starts in a name, typos in a name or + * keyword matches, or weaker), and ties keep the given order. Classes + * rather than scores keep the group order steady while a word is typed, and + * keep use, which only counts for threads, from outweighing a command that + * matches as well. + * + * Typos are only tried when no result matches every token at a word start: + * next to a real match, typo matches are noise ("enter" finding "external"), + * but they should beat a mid-word accident ("ilde" in "Builder" for "idle"). + * Skipping them also keeps a keystroke cheap. + */ +export function rankSearchGroups< + G, + T, + M extends { + readonly score: number; + readonly quality: number; + readonly strong: boolean; + readonly pickedAt?: number; + readonly pickedExactly?: boolean; + readonly groupQuality?: number; + }, +>( + groups: ReadonlyArray<{ readonly group: G; readonly items: ReadonlyArray }>, + score: (item: T, options: { readonly typos: boolean }, group: G) => M | null, +): Array<{ readonly group: G; readonly ranked: ReadonlyArray> }> { + const rank = (typos: boolean) => + groups.map(({ group, items }) => ({ + group, + ranked: items.flatMap((item) => { + const match = score(item, { typos }, group); + return match === null ? [] : [{ item, match }]; + }), + })); + let ranked = rank(false); + if (!ranked.some((entry) => entry.ranked.some(({ match }) => match.strong))) { + ranked = rank(true); + } + return ranked + .flatMap(({ group, ranked: items }, order) => { + if (items.length === 0) return []; + // Use decides within a match class, never across one: a busy thread + // matched through its project does not pass a title that holds the words. + // Picks reorder word matches but never lift a match inside a word or a + // typo above one, and a pick for a longer query ("lakebed perf") does not + // pass the result whose whole name is the query ("Lakebed"). + const wordMatch = (match: M) => Math.min(matchClass(match.quality), 2); + const exactPickAt = (match: M) => (match.pickedExactly ? (match.pickedAt ?? 0) : 0); + const sorted = [...items].sort( + (left, right) => + wordMatch(right.match) - wordMatch(left.match) || + exactPickAt(right.match) - exactPickAt(left.match) || + matchClass(right.match.quality) - matchClass(left.match.quality) || + (right.match.pickedAt ?? 0) - (left.match.pickedAt ?? 0) || + right.match.score - left.match.score, + ); + const best = Math.max( + ...items.map(({ match }) => matchClass(match.groupQuality ?? match.quality)), + ); + return [{ group, ranked: sorted, best, order }]; + }) + .sort((left, right) => right.best - left.best || left.order - right.order) + .map(({ group, ranked: items }) => ({ group, ranked: items })); +} + +/** `rankSearchGroups` for a single list. */ +export function rankSearchItems< + T, + M extends { readonly score: number; readonly quality: number; readonly strong: boolean }, +>( + items: ReadonlyArray, + score: (item: T, options: { readonly typos: boolean }) => M | null, +): ReadonlyArray> { + return rankSearchGroups([{ group: null, items }], score)[0]?.ranked ?? []; +} + +export interface SearchTermsMatch { + readonly score: number; + readonly quality: number; + /** The quality its group ranks by, when lower; see `scoreSearchTerms`. */ + readonly groupQuality?: number; + readonly strong: boolean; + readonly ranges: ReadonlyArray; +} + +const KEYWORD_QUALITY = 0.65; +// Below this, keyword prefixes match too much: "co" or "pro" would list most +// settings. A short query still matches a keyword it equals ("pr"). +const KEYWORD_QUERY_MIN_LENGTH = 4; + +/** + * Scores a non-thread result such as a command or project. The first term + * names it; the others are keywords that help it match but count less, so a + * keyword match ranks a group below a name match ("mcp" finds the thread + * named for it before a command with that keyword). Keywords match only at + * word starts, and for queries under four characters only as whole words. + */ +export function scoreSearchTerms( + normalizedQuery: string, + terms: ReadonlyArray, + options?: { readonly typos?: boolean }, +): SearchTermsMatch | null { + // Filler words ("the ci") do not make a short query long. + const meaningful = normalizedQuery + .split(" ") + .filter((token) => !FILLER_WORDS.has(token)) + .join(" "); + const short = (meaningful || normalizedQuery).length < KEYWORD_QUERY_MIN_LENGTH; + const match = matchSearchFields( + normalizedQuery, + terms.map((value, index) => + index === 0 + ? { value, weight: 1 } + : // An alias typed in full ("pr") is deliberate, above a mere name prefix. + { value, weight: short ? 0.8 : 0.6, wholeWords: short, keyword: true }, + ), + options, + ); + return ( + match && { + score: match.quality, + quality: match.quality, + // Below the word-start class: a match only in hidden keywords must not put + // its group above a result whose name matches. Inside the group an exact + // alias still beats a name prefix ("pr" lists "Open pull requests" first). + ...(match.primaryRanges.length > 0 + ? {} + : { groupQuality: Math.min(match.quality, KEYWORD_QUALITY) }), + strong: match.strong, + ranges: match.primaryRanges, + } + ); +} diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 796ce3d7af87..83c5a60c0cee 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -288,29 +288,55 @@ export function resolveThreadPullRequestBadge( return { kind: "pull-request", others: visible.length - 1, state }; } -export function threadPullRequestSearchTerms(thread: { +export interface ThreadSearchPullRequest { + readonly number: number; + readonly title: string | null; + /** `owner/repo#123` and the pull request URL, matched only by a whole query. */ + readonly references: ReadonlyArray; +} + +const SEARCHED_PULL_REQUEST_TITLES = 3; + +/** Pull requests thread search matches by number, title, and reference. */ +export function threadSearchPullRequests(thread: { readonly pullRequests?: ReadonlyArray | undefined; readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; readonly branchPullRequest?: ThreadLinkedPullRequest | null | undefined; -}): string[] { - const branch = thread.branchPullRequest; - const terms = branch - ? [`#${branch.number}`, `${branch.repository}#${branch.number}`, branch.url] - : []; +}): ThreadSearchPullRequest[] { + const entry = ( + link: Pick, + title: string | null, + ): ThreadSearchPullRequest => ({ + number: link.number, + title, + references: [`${link.repository}#${link.number}`, link.url], + }); + const branch = thread.branchPullRequest ? [entry(thread.branchPullRequest, null)] : []; if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) { - return terms.concat( - visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [ - `#${link.number}`, - `${link.repository}#${link.number}`, - link.url, - link.snapshot?.title ?? "", - ]), + // Every pull request matches by number and link, but only the newest few + // by title: a triage thread linked to dozens would match almost any word. + const newestFirst = [...visibleThreadPullRequests(thread.pullRequests)].sort( + (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left), + ); + return branch.concat( + newestFirst.map((link, index) => + entry(link, index < SEARCHED_PULL_REQUEST_TITLES ? (link.snapshot?.title ?? null) : null), + ), ); } - const legacy = thread.linkedPullRequest; - return legacy - ? terms.concat([`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url]) - : terms; + return thread.linkedPullRequest ? branch.concat(entry(thread.linkedPullRequest, null)) : branch; +} + +export function threadPullRequestSearchTerms(thread: { + readonly pullRequests?: ReadonlyArray | undefined; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + readonly branchPullRequest?: ThreadLinkedPullRequest | null | undefined; +}): string[] { + return threadSearchPullRequests(thread).flatMap((pullRequest) => [ + `#${pullRequest.number}`, + ...pullRequest.references, + ...(pullRequest.title ? [pullRequest.title] : []), + ]); } /** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */ diff --git a/packages/shared/src/threadSearch.ts b/packages/shared/src/threadSearch.ts index df86c3b94b2c..5cca036d7bed 100644 --- a/packages/shared/src/threadSearch.ts +++ b/packages/shared/src/threadSearch.ts @@ -1,9 +1,3 @@ -interface ThreadSearchTextPart { - readonly text: string; - readonly highlighted: boolean; - readonly start: number; -} - function foldThreadSearchCase(text: string): string { const lowered = text.toLowerCase().replace(/ς/g, "σ"); if (lowered.length === text.length) return lowered; @@ -43,28 +37,3 @@ export function findThreadSearchOccurrences(text: string, query: string): number scanThreadSearchOccurrences(text, query, offsets); return offsets; } - -export function splitThreadSearchText(text: string, query: string): ThreadSearchTextPart[] { - const normalizedQuery = query.trim(); - if (normalizedQuery.length === 0) { - return [{ text, highlighted: false, start: 0 }]; - } - - const parts: ThreadSearchTextPart[] = []; - let cursor = 0; - for (const offset of findThreadSearchOccurrences(text, normalizedQuery)) { - if (offset > cursor) { - parts.push({ text: text.slice(cursor, offset), highlighted: false, start: cursor }); - } - parts.push({ - text: text.slice(offset, offset + normalizedQuery.length), - highlighted: true, - start: offset, - }); - cursor = offset + normalizedQuery.length; - } - if (cursor < text.length) { - parts.push({ text: text.slice(cursor), highlighted: false, start: cursor }); - } - return parts; -} From 9a85aac901ff22045d8a70885b91bb4322e35431 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:33 -0700 Subject: [PATCH 02/65] refactor(source-control): forges parse their own change request URLs (#23992) Co-authored-by: Claude Opus 5.5 --- .../HardwareKeyboardCommandProvider.tsx | 2 +- .../pullRequestNavigation.test.ts | 51 ++- .../pull-requests/usePullRequestStack.ts | 6 +- .../pull-requests/usePullRequestThreads.ts | 6 +- .../threads/LinkedPullRequestList.tsx | 2 +- .../threads/new-task-context-presentation.ts | 4 +- apps/mobile/src/lib/pullRequestNavigation.ts | 23 +- apps/mobile/src/native/useOpenPullRequest.ts | 7 +- .../src/state/thread-pr-presentation.ts | 2 +- apps/server/src/git/GitManager.ts | 8 +- apps/server/src/git/linkCreatedPullRequest.ts | 8 +- apps/server/src/mcp/OrchestratorMcpService.ts | 2 +- .../toolkits/pullRequests/handlers.test.ts | 56 +-- .../src/mcp/toolkits/pullRequests/handlers.ts | 64 +-- .../src/mcp/toolkits/pullRequests/tools.ts | 10 + .../src/orchestration-v2/Orchestrator.ts | 4 +- .../src/orchestration-v2/ProjectionStore.ts | 4 +- .../PullRequestSyncReactor.ts | 20 +- .../PullRequestWatchReactor.ts | 2 +- .../legacy/LegacyV1ThreadImporter.ts | 2 +- .../050_ProjectionThreadPullRequests.ts | 2 +- .../064_CanonicalizeThreadPullRequestLinks.ts | 2 +- .../Migrations/legacyThreadPullRequestKey.ts | 109 +++++ apps/server/src/pullRequest/linkedThreads.ts | 2 +- .../src/pullRequest/pullRequestSyncKey.ts | 2 +- .../SourceControlDiscovery.test.ts | 16 +- .../src/sourceControl/builtInDrivers.ts | 16 +- .../src/sourceControl/sourceControlClients.ts | 27 ++ .../sourceControl/sourceControlHostSecrets.ts | 18 +- .../src/sourceControl/threadPullRequests.ts | 15 + .../BranchToolbarBranchSelector.tsx | 2 +- .../ChatMarkdown.permissions.browser.test.tsx | 12 +- apps/web/src/components/ChatMarkdown.tsx | 21 +- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/CommandPalette.tsx | 2 +- apps/web/src/components/LegacySidebar.tsx | 2 +- apps/web/src/components/RightPanelTabs.tsx | 2 +- .../src/components/ThreadStatusIndicators.tsx | 7 +- .../components/chat/ThreadDetailsPrRows.tsx | 9 +- .../desktop/DesktopWebLinkCoordinator.tsx | 4 +- .../pullRequest/PullRequestDetailPanel.tsx | 28 +- .../pullRequest/PullRequestThreadLinks.tsx | 6 +- .../PullRequestsUnavailableState.tsx | 12 +- .../pullRequest/ThreadPullRequestsPanel.tsx | 5 +- .../pullRequest/pullRequestDetail.logic.ts | 16 +- .../components/sidebar/SidebarThreadRow.tsx | 2 +- .../src/hooks/useOpenPanelPullRequestUrl.ts | 8 +- .../src/hooks/usePullRequestLinking.test.ts | 6 +- apps/web/src/hooks/usePullRequestLinking.ts | 27 +- apps/web/src/lib/openPullRequestLink.test.ts | 316 +-------------- apps/web/src/lib/openPullRequestLink.ts | 70 +--- apps/web/src/state/pullRequests.ts | 2 +- packages/client-runtime/package.json | 12 + .../src/changeRequestLinks.test.ts | 104 +++++ .../client-runtime/src/changeRequestLinks.ts | 77 ++++ packages/client-runtime/src/promptLinks.ts | 2 +- .../src/pullRequestLinkInput.ts | 19 +- .../src/sourceControlClients.test.ts | 240 +++++++++++ .../src/sourceControlClients.ts | 8 +- packages/client-runtime/src/state/models.ts | 2 +- .../src/state/orchestrationV2Projection.ts | 2 +- .../src/threadPullRequestCompatibility.ts | 2 +- .../src/threadPullRequests.test.ts | 0 .../client-runtime/src/threadPullRequests.ts | 28 ++ .../src/threadReference.test.ts | 0 .../src/threadReference.ts | 0 .../src/work-log/presentation.ts | 7 +- packages/shared/package.json | 8 - packages/shared/src/changeRequestUrl.test.ts | 197 ---------- packages/shared/src/changeRequestUrl.ts | 216 ---------- .../orchestrationV2PendingBackgroundWork.ts | 5 +- packages/shared/src/threadPullRequests.ts | 343 +--------------- .../src/client/definition.test.ts | 104 +++++ .../src/client/definition.ts | 23 +- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 38 ++ .../src/client/definition.ts | 25 +- .../src/server/driver.ts | 6 +- packages/source-control-core/package.json | 4 + .../src/client/definition.ts | 139 ++++++- .../src/client/threadPullRequests.ts | 372 ++++++++++++++++++ .../source-control-core/src/server/driver.ts | 3 + .../src/client/definition.test.ts | 104 +++++ .../src/client/definition.ts | 31 +- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 49 +++ .../src/client/definition.ts | 31 +- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 96 +++++ .../src/client/definition.ts | 49 ++- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 50 +++ .../src/client/definition.ts | 16 +- .../GitLabSourceControlProvider.test.ts | 22 +- .../src/server/driver.ts | 6 +- 95 files changed, 2036 insertions(+), 1481 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts create mode 100644 apps/server/src/sourceControl/sourceControlClients.ts create mode 100644 apps/server/src/sourceControl/threadPullRequests.ts create mode 100644 packages/client-runtime/src/changeRequestLinks.test.ts create mode 100644 packages/client-runtime/src/changeRequestLinks.ts create mode 100644 packages/client-runtime/src/sourceControlClients.test.ts rename packages/{shared => client-runtime}/src/threadPullRequests.test.ts (100%) create mode 100644 packages/client-runtime/src/threadPullRequests.ts rename packages/{shared => client-runtime}/src/threadReference.test.ts (100%) rename packages/{shared => client-runtime}/src/threadReference.ts (100%) delete mode 100644 packages/shared/src/changeRequestUrl.test.ts delete mode 100644 packages/shared/src/changeRequestUrl.ts create mode 100644 packages/source-control-azure-devops/src/client/definition.test.ts create mode 100644 packages/source-control-bitbucket/src/client/definition.test.ts create mode 100644 packages/source-control-core/src/client/threadPullRequests.ts create mode 100644 packages/source-control-forgejo/src/client/definition.test.ts create mode 100644 packages/source-control-gitcafe/src/client/definition.test.ts create mode 100644 packages/source-control-github/src/client/definition.test.ts create mode 100644 packages/source-control-gitlab/src/client/definition.test.ts diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index ada99e156960..cdbfc7d446de 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -1,5 +1,5 @@ import { StackActions, useNavigation } from "@react-navigation/native"; -import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { createContext, use, diff --git a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts index 23a2d4c8ed94..4d1a4ab5addb 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts @@ -1,26 +1,61 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { nativePullRequestDestination } from "../../lib/pullRequestNavigation"; + const context = { environmentId: "remote", projectId: "project" }; +const project = (id: string, canonicalKey: string, provider: string, remoteUrl: string) => ({ + id: ProjectId.make(id), + environmentId: EnvironmentId.make("remote"), + repositoryIdentity: { + canonicalKey, + locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl }, + displayName: canonicalKey.split("/").slice(1).join("/"), + provider, + }, +}); +const projects = [ + project("project", "github.com/org/repo", "github", "git@github.com:org/repo.git"), + project("gitlab", "git.example/group/sub/repo", "gitlab", "git@git.example:group/sub/repo.git"), + project("forgejo", "forgejo.test/org/repo", "forgejo", "http://forgejo.test:3000/org/repo.git"), +]; + describe("native PR entry points", () => { it("keeps a thread link in its own environment and handles renamed host path shapes", () => { expect( - nativePullRequestDestination("https://github.com/org/repo/pull/12/files#diff", context), + nativePullRequestDestination( + "https://github.com/org/repo/pull/12/files#diff", + context, + projects, + ), ).toEqual({ ...context, host: "github.com", repository: "org/repo", number: 12 }); expect( - nativePullRequestDestination("https://git.example/group/sub/repo/-/merge_requests/9", context) - ?.repository, - ).toBe("group/sub/repo"); + nativePullRequestDestination( + "https://git.example/group/sub/repo/-/merge_requests/9", + context, + projects, + ), + ).toMatchObject({ projectId: "gitlab", repository: "group/sub/repo" }); expect( - nativePullRequestDestination("http://forgejo.test:3000/org/repo/pulls/3", context)?.host, + nativePullRequestDestination("http://forgejo.test:3000/org/repo/pulls/3", context, projects) + ?.host, ).toBe("forgejo.test:3000"); }); + + it("leaves pull requests from repositories without a project to the external opener", () => { + expect( + nativePullRequestDestination("https://github.com/someone/else/pull/12", context, projects), + ).toBeNull(); + }); + it("leaves issues, unknown sites, and unscoped links to the external opener", () => { expect( - nativePullRequestDestination("https://github.com/org/repo/issues/12", context), + nativePullRequestDestination("https://github.com/org/repo/issues/12", context, projects), + ).toBeNull(); + expect( + nativePullRequestDestination("https://example.com/org/repo/pull/12", context, projects), ).toBeNull(); expect( - nativePullRequestDestination("https://example.com/org/repo/pull/12", context), + nativePullRequestDestination("https://github.com/org/repo/pull/12", null, projects), ).toBeNull(); - expect(nativePullRequestDestination("https://github.com/org/repo/pull/12", null)).toBeNull(); }); }); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts index 85e38795d8cd..50a3d11f0d3e 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts @@ -1,6 +1,6 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import type { PullRequestDetail } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { useMemo } from "react"; import { useEnvironmentServerConfig, useNavigationThreadShells } from "../../state/entities"; import { pullRequestStackAtom } from "../../state/pull-requests"; @@ -18,7 +18,9 @@ export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDe const reference = useMemo( () => ({ ...pullRequestTarget(params).input, - host: params.host ?? (pr ? parseChangeRequestUrl(pr.url)?.host : undefined), + host: + params.host ?? + (pr ? sourceControlClients.parseChangeRequestUrl(pr.url)?.link.host : undefined), }), [params, pr], ); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index e845c960e58a..ba8abcbff8ed 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts @@ -11,8 +11,8 @@ import { type PullRequestLinkedThreadsResult, } from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useRef, useState } from "react"; import { Platform } from "react-native"; import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; @@ -49,7 +49,7 @@ export function usePullRequestThreads( const target = pullRequestTarget(params); const config = useEnvironmentServerConfig(target.environmentId); const supported = threadPullRequestLinkMode(config?.environment.capabilities) === "multiple"; - const parsed = pr ? parseChangeRequestUrl(pr.url) : null; + const parsed = pr ? sourceControlClients.parseChangeRequestUrl(pr.url)?.link : undefined; const reference = { ...target, input: { ...target.input, ...(parsed ? normalizeThreadPullRequestKey(parsed) : {}) }, diff --git a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx index 903e7fd574bb..2082b8753683 100644 --- a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx +++ b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx @@ -4,7 +4,7 @@ import { resolveThreadPullRequestChains, threadPullRequestKeyOf, type PullRequestListLine, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { memo, useMemo } from "react"; import { Pressable, useWindowDimensions, View } from "react-native"; diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index bd2add22eb64..b1dbd5206ada 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -1,5 +1,5 @@ import { sanitizeNewRefName } from "@t3tools/shared/git"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveSendEnvMode } from "@t3tools/shared/projectSettings"; import type { ComposerDraftWorkspaceSelection } from "../../state/use-composer-drafts"; @@ -79,7 +79,7 @@ export function resolveNewTaskBranchLabel(input: { readonly pullRequestReference?: string; }): string { if (input.workspaceMode === "worktree" && input.pullRequestReference) { - const pr = parseChangeRequestUrl(input.pullRequestReference); + const pr = sourceControlClients.parseChangeRequestUrl(input.pullRequestReference)?.link; if (pr) return `From PR #${pr.number}`; } if (!input.branchName) { diff --git a/apps/mobile/src/lib/pullRequestNavigation.ts b/apps/mobile/src/lib/pullRequestNavigation.ts index 98cc6d7273d0..1c84cfb6876d 100644 --- a/apps/mobile/src/lib/pullRequestNavigation.ts +++ b/apps/mobile/src/lib/pullRequestNavigation.ts @@ -1,13 +1,30 @@ -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +/** + * Opens a change request in the app only under a project of the link's environment whose own + * repository holds it, preferring the one the link came from; anything else is the browser's. + */ export function nativePullRequestDestination( url: string, context: { environmentId: string; projectId: string } | null, + projects: ReadonlyArray>, ) { - const parsed = parseChangeRequestUrl(url); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; if (!parsed || !context) return null; + const candidates = projects.filter((project) => project.environmentId === context.environmentId); + const project = findProjectForChangeRequest( + [ + ...candidates.filter((candidate) => candidate.id === context.projectId), + ...candidates.filter((candidate) => candidate.id !== context.projectId), + ], + parsed, + ); + if (project === undefined) return null; return { - ...context, + environmentId: context.environmentId, + projectId: String(project.id), repository: parsed.repository, number: parsed.number, host: parsed.authority ?? parsed.host, diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index 48d895de7a9f..096906a1716b 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -4,7 +4,7 @@ import { useCallback } from "react"; import { Platform } from "react-native"; import { resetAndroidWorkspace } from "../lib/android-workspace-navigation"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { useThreadShell } from "../state/entities"; +import { useProjects, useThreadShell } from "../state/entities"; import { tryOpenExternalUrl } from "../lib/openExternalUrl"; type PullRequestContext = { readonly environmentId: string; readonly projectId: string }; @@ -13,10 +13,11 @@ type PullRequestContext = { readonly environmentId: string; readonly projectId: // route context for useRoute, can open pull requests for any thread. export function useNavigateToPullRequest(options?: { readonly replace?: boolean }) { const navigation = useNavigation(); + const projects = useProjects(); const replace = options?.replace; return useCallback( async (url: string, context: PullRequestContext | null) => { - const params = nativePullRequestDestination(url, context); + const params = nativePullRequestDestination(url, context, projects); if (!params) return tryOpenExternalUrl(url, "pull-request"); if (Platform.OS === "android") resetAndroidWorkspace(navigation, { name: "PullRequest", params }); @@ -24,7 +25,7 @@ export function useNavigateToPullRequest(options?: { readonly replace?: boolean else navigation.navigate("PullRequest", params); return true; }, - [navigation, replace], + [navigation, projects, replace], ); } diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index bd6ed87e3bb1..7e4bcc505a9a 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -9,7 +9,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { resolveThreadCurrentPullRequestLink, resolveThreadPullRequestBadge, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; export type ThreadPr = NonNullable; diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index ae1b22157a99..4691f81fda89 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -62,7 +62,6 @@ import { isSshRemoteUrl, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { parseGitHubRepositorySelector, @@ -71,7 +70,8 @@ import { import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import { @@ -1202,8 +1202,8 @@ export const make = Effect.gen(function* () { >(); const noteLookupState = (pr: PullRequestInfo) => Effect.suspend(() => { - const parsed = parseChangeRequestUrl(pr.url); - if (parsed === null || parsed.number !== pr.number) return Effect.void; + const parsed = sourceControlClients.parseChangeRequestUrl(pr.url)?.link; + if (parsed === undefined || parsed.number !== pr.number) return Effect.void; const key = normalizeThreadPullRequestKey(parsed); const id = threadPullRequestKeyOf(key); const previous = lookupStates.get(id); diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts index 2b687d0832b3..29e80f9c13c4 100644 --- a/apps/server/src/git/linkCreatedPullRequest.ts +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -7,13 +7,13 @@ import { type SourceControlProviderKind, type ThreadId, } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; export interface CreatedPullRequestKey { readonly host: string; @@ -30,8 +30,10 @@ export function createdPullRequestKey( if ((status !== "created" && status !== "opened_existing") || number === undefined || !url) { return null; } - const parsed = parseChangeRequestUrl(url); - if (parsed !== null) return { ...parsed, url }; + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + if (parsed !== undefined) { + return { host: parsed.host, repository: parsed.repository, number: parsed.number, url }; + } const identity = project?.repositoryIdentity; const kind = identity?.provider as SourceControlProviderKind | undefined; const repository = sourceControlRepositorySelector(identity); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 8f39c2e452a6..53d57406c7cb 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -70,7 +70,7 @@ import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index c49715eb7eb7..11bce07bf5ba 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -390,44 +390,22 @@ describe("pull request toolkit handlers", () => { }); expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42"); expect(result.host).toBe("gitlab.com"); - }), - ); - - it.effect("links a numeric Forgejo reference with its remote's web origin and mount path", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - project: makeProject({ - canonicalKey: "forge.example/git/owner/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "http://forge.example:3000/git/owner/repo.git", - }, - provider: "forgejo", - displayName: "git/owner/repo", - }), - }); - const result = yield* harness.call("link_pull_request", { - repository: "git/owner/repo", - number: 42, - }); - expect(result).toEqual({ - host: "forge.example:3000", - repository: "git/owner/repo", - number: 42, - url: "http://forge.example:3000/git/owner/repo/pulls/42", - alreadyLinked: false, - }); const other = yield* harness.call("link_pull_request", { - host: "other.example", - repository: "owner/repo", - number: 42, + host: "gitlab.example.test", + repository: "team/repo", + number: 7, + }); + expect(other.url).toBe("https://gitlab.example.test/team/repo/-/merge_requests/7"); + const onGitHub = yield* harness.call("link_pull_request", { + host: "github.com", + repository: "acme/web", + number: 9, }); - expect(other.url).toBe("https://other.example/owner/repo/pull/42"); + expect(onGitHub.url).toBe("https://github.com/acme/web/pull/9"); }), ); - it.effect("links Azure DevOps Server URLs and numeric references without a known provider", () => + it.effect("links a URL but not a bare number when the project's host is unknown", () => Effect.gen(function* () { const harness = yield* makeHarness({ project: makeProject({ @@ -441,13 +419,10 @@ describe("pull request toolkit handlers", () => { displayName: "tfs/collection/project/_git/repo", }), }); - const generated = - "http://scm.example.test:8080/tfs/collection/project/_git/repo/pullrequest/124"; - const fromNumber = yield* harness.call("link_pull_request", { - repository: "tfs/Collection/Project/_git/Repo", - number: 124, - }); - expect.soft(fromNumber.url).toBe(generated); + const error = yield* harness + .call("link_pull_request", { repository: "tfs/Collection/Project/_git/Repo", number: 124 }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestUrlUnavailableError" }); const supplied = "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123"; const fromUrl = yield* harness.call("link_pull_request", { url: supplied }); @@ -459,7 +434,6 @@ describe("pull request toolkit handlers", () => { alreadyLinked: false, }); expect(yield* Ref.get(harness.commands)).toMatchObject([ - { type: "thread.pull-request.link", number: 124, url: generated }, { type: "thread.pull-request.link", number: 123, url: supplied }, ]); }), diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index c777214aad5c..ff323fcdd948 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -1,7 +1,3 @@ -import { - threadPullRequestsOf, - threadPullRequestKeysEqual, -} from "@t3tools/shared/threadPullRequests"; import { CommandId, pullRequestHostOf, @@ -11,14 +7,7 @@ import { type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; -import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { - normalizeThreadPullRequestKey, - resolveThreadPullRequestChains, - threadPullRequestKeyOf, - visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -28,6 +17,15 @@ import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts"; import { pullRequestSyncKey } from "../../../pullRequest/pullRequestSyncKey.ts"; +import { sourceControlClients } from "../../../sourceControl/sourceControlClients.ts"; +import { + normalizeThreadPullRequestKey, + resolveThreadPullRequestChains, + threadPullRequestKeyOf, + threadPullRequestKeysEqual, + threadPullRequestsOf, + visibleThreadPullRequests, +} from "../../../sourceControl/threadPullRequests.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { @@ -36,6 +34,7 @@ import { PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, + PullRequestUrlUnavailableError, PullRequestUnlinkFailedError, PullRequestListFailedError, PullRequestNotOpenError, @@ -75,8 +74,8 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( project: OrchestrationProjectShell | undefined, ) { if (input.url !== undefined) { - const parsed = parseChangeRequestUrl(input.url); - if (parsed === null) { + const parsed = sourceControlClients.parseChangeRequestUrl(input.url)?.link; + if (parsed === undefined) { return yield* new PullRequestUrlInvalidError({}); } return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget; @@ -90,24 +89,21 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( return yield* new PullRequestHostRequiredError({}); } const repository = input.repository.toLowerCase(); + // Without a URL, a public forge host says what shape its change request URLs take; + // any other host is taken to run the project's own forge. + const kind = sourceControlClients.findByPublicHost(host)?.kind ?? projectHost.kind; const url = - changeRequestUrlFor( - host === projectHost.host ? projectHost.kind : null, - host, - repository, - input.number, - project?.repositoryIdentity?.locator.remoteUrl, - ) ?? - (/(?:^|\/)_git\/[^/]+$/u.test(repository) - ? changeRequestUrlFor( - "azure-devops", + kind === null + ? null + : sourceControlClients.get(kind).changeRequestUrl({ host, repository, - input.number, - project?.repositoryIdentity?.locator.remoteUrl, - ) - : null) ?? - `https://${host}/${repository}/pull/${input.number}`; + number: input.number, + remoteUrl: project?.repositoryIdentity?.locator.remoteUrl, + }); + if (url === null) { + return yield* new PullRequestUrlUnavailableError({}); + } return { ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }), url, @@ -314,8 +310,18 @@ const make = Effect.gen(function* () { .listShells() .pipe(Effect.mapError((cause) => new PullRequestListFailedError({ cause }))); const callerProject = known.find((project) => project.id === callerThread?.projectId); + // A pull request named by host and repository builds its URL through the project that holds + // it, which need not be the caller's. + const urlProject = (input: PullRequestTargetInput) => + input.url === undefined && input.host !== undefined && input.repository !== undefined + ? (projectForPullRequest( + known, + { host: input.host, repository: input.repository }, + callerProject, + ) ?? callerProject) + : callerProject; return yield* Effect.forEach(targets, (input) => - resolveTarget(input, callerProject).pipe( + resolveTarget(input, urlProject(input)).pipe( Effect.map((target) => ({ input, target, diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index a3a75eabaa38..510f6831d7eb 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -93,6 +93,15 @@ export class PullRequestHostRequiredError extends Schema.TaggedError()( + "PullRequestUrlUnavailableError", + {}, +) { + override get message(): string { + return "This thread's project is on a host with no known pull request URL shape. Pass url."; + } +} + export class PullRequestThreadRequiredError extends Schema.TaggedError()( "PullRequestThreadRequiredError", {}, @@ -189,6 +198,7 @@ export const PullRequestToolError = Schema.Union([ PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, + PullRequestUrlUnavailableError, PullRequestThreadRequiredError, PullRequestThreadNotFoundError, PullRequestLinkFailedError, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1a650c81f747..77533f593e9a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -9,13 +9,13 @@ import { runRanAfter, usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, threadPullRequestKeysEqual, legacyThreadPullRequestKey, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import { ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type ChatAttachment, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 8deb5bf7e93f..a50ce6ff3524 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,4 +1,4 @@ -import { applyThreadPullRequestLinkUpdate } from "@t3tools/shared/threadPullRequests"; +import { applyThreadPullRequestLinkUpdate } from "../sourceControl/threadPullRequests.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; import * as Stream from "effect/Stream"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -14,7 +14,7 @@ import { threadErrorSummary, usageLimitRunPresentedAsLatest, } from "@t3tools/shared/orchestrationV2ThreadError"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import type { OrchestrationV2AppThread, OrchestrationV2Checkpoint, diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index af7ecdadb12c..5e3dbbb6d937 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -1,4 +1,3 @@ -import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, type PullRequestSummary, @@ -14,7 +13,8 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -53,6 +53,20 @@ interface LinkEntry { readonly link: ThreadPullRequestLink; } +/** Another layer of the stack `url` belongs to, in its host's canonical shape. */ +function siblingChangeRequestUrl(url: string, number: number): string | null { + if (!Number.isSafeInteger(number) || number < 1) return null; + const parsed = sourceControlClients.parseChangeRequestUrl(url); + if (parsed === null) return null; + const { definition, link } = parsed; + return definition.changeRequestUrl({ + host: link.host, + repository: link.repository, + number, + webUrl: link.repositoryUrl, + }); +} + function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields { return { state: summary.state, @@ -287,7 +301,7 @@ export const make = Effect.gen(function* () { ) { continue; } - const url = siblingPullRequestUrl(link.url, layer.number); + const url = siblingChangeRequestUrl(link.url, layer.number); if (url === null) continue; const uuid = yield* crypto.randomUUIDv4(); yield* engine.dispatch({ diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 30152d305382..37631ce41a07 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -13,7 +13,7 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index 610be4c1ba3a..30403ea527f0 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -1,7 +1,7 @@ import { threadPullRequestKeysEqual, threadPullRequestsOf, -} from "@t3tools/shared/threadPullRequests"; +} from "../../sourceControl/threadPullRequests.ts"; import { ChatAttachment, OrchestrationMessageContext, diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts index 0ee0f00c0b33..f130aae7e10a 100644 --- a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts +++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts @@ -1,4 +1,4 @@ -import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { legacyThreadPullRequestKey } from "./legacyThreadPullRequestKey.ts"; import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts index 7135696246c6..9364a8d817c4 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts @@ -1,5 +1,5 @@ import { ThreadLinkedPullRequest, ThreadPullRequestLink } from "@t3tools/contracts"; -import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { legacyThreadPullRequestKey } from "./legacyThreadPullRequestKey.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; diff --git a/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts b/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts new file mode 100644 index 000000000000..c5c0f80b8d9d --- /dev/null +++ b/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts @@ -0,0 +1,109 @@ +import type { ThreadLinkedPullRequest, ThreadPullRequestKey } from "@t3tools/contracts"; + +/** + * Frozen for migrations 050 and 064: the change request URL parsing and key normalization they + * shipped with. Migrations are history, so they must not follow the live source control hosts. + */ + +interface FrozenLink { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly authority?: string; +} + +function canonicalRepositoryKey(key: string): string { + return key + .replace( + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ) + .replace( + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ); +} + +function isHostOf(hostname: string, apex: string, label: string): boolean { + return hostname === apex || hostname.endsWith(`.${apex}`) || hostname.split(".").includes(label); +} + +function claim(host: string, match: RegExpExecArray | null): FrozenLink | null { + const repository = match?.[1]; + const number = Number(match?.[2]); + return repository && Number.isSafeInteger(number) && number > 0 + ? { host, repository: repository.toLowerCase(), number } + : null; +} + +function parseChangeRequestUrl(targetUrl: string): FrozenLink | null { + let url: URL; + try { + url = new URL(targetUrl); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const host = url.hostname.toLowerCase(); + + if (host === "git.cafe" || host === "staging.git.cafe") { + const match = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } else { + if (isHostOf(host, "github.com", "github")) { + const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } + const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (forgejo) { + const link = claim(host, forgejo); + return link === null ? null : { ...link, authority: url.host.toLowerCase() }; + } + const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (gitlab) return claim(host, gitlab); + if (isHostOf(host, "bitbucket.org", "bitbucket")) { + const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } + } + const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); + const link = claim(host, azure); + return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; +} + +function normalizeThreadPullRequestKey( + key: ThreadPullRequestKey & { readonly authority?: string }, +): ThreadPullRequestKey { + const canonical = canonicalRepositoryKey( + `${(key.authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, + ); + const separator = canonical.indexOf("/"); + return { + host: canonical.slice(0, separator), + repository: canonical.slice(separator + 1), + number: key.number, + }; +} + +export function legacyThreadPullRequestKey( + linked: Pick, +): ThreadPullRequestKey { + const parsed = parseChangeRequestUrl(linked.url); + if (parsed !== null && parsed.number === linked.number) { + const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); + if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { + return normalizeThreadPullRequestKey(parsed); + } + } + let host: string; + try { + host = new URL(linked.url).hostname; + } catch { + host = "unknown"; + } + return { + host: host.trim().toLowerCase() || "unknown", + repository: linked.repository.trim().toLowerCase(), + number: linked.number, + }; +} diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index fa51017ebcd5..c7a2b01946a2 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -1,7 +1,7 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeysEqual, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import { PullRequestLinkedThreadsResult, PullRequestOperationError, diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts index 3b5ecc380d03..90416e066ca0 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -6,7 +6,7 @@ import { type ThreadPullRequestKey, } from "@t3tools/contracts"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { normalizeThreadPullRequestKey } from "../sourceControl/threadPullRequests.ts"; export function pullRequestSyncKey( reference: PullRequestRef, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index aad475ab54af..43f3a9ff8680 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -217,27 +217,27 @@ Logged in to gitlab.com as gitlab-user detail: Option.none(), }, { - kind: SourceControlProviderKind.make("azure-devops"), + kind: SourceControlProviderKind.make("gitcafe"), auth: "authenticated", - account: Option.some("azure-user@example.com"), + account: Option.some("cafe-user"), detail: Option.none(), }, { - kind: SourceControlProviderKind.make("bitbucket"), + kind: SourceControlProviderKind.make("forgejo"), auth: "authenticated", - account: Option.some("bitbucket-user"), + account: Option.some("forgejo-user"), detail: Option.none(), }, { - kind: SourceControlProviderKind.make("forgejo"), + kind: SourceControlProviderKind.make("bitbucket"), auth: "authenticated", - account: Option.some("forgejo-user"), + account: Option.some("bitbucket-user"), detail: Option.none(), }, { - kind: SourceControlProviderKind.make("gitcafe"), + kind: SourceControlProviderKind.make("azure-devops"), auth: "authenticated", - account: Option.some("cafe-user"), + account: Option.some("azure-user@example.com"), detail: Option.none(), }, ], diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 9af54853a049..7941c7f3eadc 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -1,34 +1,20 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; -import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; - -const drivers = [ - GitHubDriver.driver, - GitLabDriver.driver, - AzureDevOpsDriver.driver, - BitbucketDriver.driver, - ForgejoDriver.driver, - GitCafeDriver.driver, -]; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST as drivers } from "./sourceControlClients.ts"; export type BuiltInSourceControlDriversEnv = (typeof drivers)[number] extends SourceControlDriver ? R : never; diff --git a/apps/server/src/sourceControl/sourceControlClients.ts b/apps/server/src/sourceControl/sourceControlClients.ts new file mode 100644 index 000000000000..bfe6cc8fecaa --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlClients.ts @@ -0,0 +1,27 @@ +import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; +import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; +import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; +import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; +import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; +import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; + +/** + * The same order as the clients' registry in `@t3tools/client-runtime/source-control-clients`. + * A change request URL goes to the first host whose shape it has, so GitCafe, which claims + * `/pull/` and `/pulls/` only on its own hostnames, claims before GitHub and Forgejo, which + * claim them everywhere. + */ +export const BUILT_IN_SOURCE_CONTROL_DRIVER_LIST = [ + GitHubDriver.driver, + GitLabDriver.driver, + GitCafeDriver.driver, + ForgejoDriver.driver, + BitbucketDriver.driver, + AzureDevOpsDriver.driver, +] as const; + +export const sourceControlClients = makeSourceControlClientRegistry( + BUILT_IN_SOURCE_CONTROL_DRIVER_LIST.map((driver) => driver.definition), + { claimChangeRequestUrlsFirst: [GitCafeDriver.driver.kind] }, +); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 8ac8046985ce..ab366398be3d 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -1,26 +1,12 @@ import { secretSettingsFields } from "@t3tools/contracts"; -import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; -import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; -import type { SourceControlClientDefinition } from "@t3tools/source-control-core/client/definition"; -import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; -import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; -import * as GitHub from "@t3tools/source-control-github/client/definition"; -import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; -const definitions: ReadonlyArray = [ - GitHub.definition, - GitLab.definition, - AzureDevOps.definition, - Bitbucket.definition, - Forgejo.definition, - GitCafe.definition, -]; +import { sourceControlClients } from "./sourceControlClients.ts"; export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< string, ReadonlyArray > = new Map( - definitions.flatMap((definition) => { + sourceControlClients.definitions.flatMap((definition) => { const fields = definition.settings ? secretSettingsFields(definition.settings) : []; return fields.length > 0 ? [[definition.kind, fields] as const] : []; }), diff --git a/apps/server/src/sourceControl/threadPullRequests.ts b/apps/server/src/sourceControl/threadPullRequests.ts new file mode 100644 index 000000000000..148fc92ac9e2 --- /dev/null +++ b/apps/server/src/sourceControl/threadPullRequests.ts @@ -0,0 +1,15 @@ +import { makeThreadPullRequests } from "@t3tools/source-control-core/client/threadPullRequests"; + +import { sourceControlClients } from "./sourceControlClients.ts"; + +export { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; + +export const { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadPullRequestChains, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, +} = makeThreadPullRequests(sourceControlClients); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 84cb807f45fb..cb3df202217f 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,7 +1,7 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useRightPanelStore } from "../rightPanelStore"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index de274fdd433b..2ea19680499b 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -95,20 +95,10 @@ vi.mock(import("../localApi"), async (original) => ({ shell: { openExternal: state.openExternal }, }) as never, })); -vi.mock(import("~/lib/openPullRequestLink"), async (original) => ({ +vi.mock(import("@t3tools/client-runtime/change-request-links"), async (original) => ({ ...(await original()), resolvePullRequestPreviewTarget: () => null, findProjectForChangeRequest: ((projects: readonly { id: ProjectId }[]) => projects[0]) as never, - matchesLinkedPullRequestUrl: (candidate: ThreadLinkedPullRequest, href: string) => - candidate.url === href, - parseChangeRequestUrl: ((href: string) => - href === linkedPullRequest.url - ? { - host: "github.com", - repository: linkedPullRequest.repository, - number: linkedPullRequest.number, - } - : null) as never, })); import ChatMarkdown from "./ChatMarkdown"; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 91acb81d1730..e46e3092d6b0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -167,12 +167,9 @@ import { stripRepeatedWorkspacePrefix, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "@t3tools/client-runtime/workspace-basename-lookup"; -import { - parseChangeRequestUrl, - pullRequestCandidateUrlFromReferenceAutolink, - resolvePullRequestPreviewTarget, - useOpenChangeRequestLink, -} from "~/lib/openPullRequestLink"; +import { resolvePullRequestPreviewTarget } from "@t3tools/client-runtime/change-request-links"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; @@ -679,14 +676,14 @@ function useChatMarkdownState({ !pullRequestLinking.canLink(href) ) return null; - const parsed = parseChangeRequestUrl(href); - return parsed === null ? null : { ...parsed, url: href }; + const parsed = sourceControlClients.parseChangeRequestUrl(href)?.link; + return parsed === undefined ? null : { ...parsed, url: href }; }; const linkedThreadPullRequestFor = (href: string) => { if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href)) return null; - const parsed = parseChangeRequestUrl(href); - return parsed === null ? null : { ...parsed, url: href }; + const parsed = sourceControlClients.parseChangeRequestUrl(href)?.link; + return parsed === undefined ? null : { ...parsed, url: href }; }; const updateThreadPullRequestLink = async (href: string, linked: boolean) => { if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return; @@ -1109,7 +1106,9 @@ const CHAT_MARKDOWN_COMPONENTS = { const isPullRequestAutolink = pullRequestCopy !== undefined; const confirmBeforeOpen = pullRequestAutolink === "reference"; const pullRequestCandidateUrl = - confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; + confirmBeforeOpen && href + ? sourceControlClients.changeRequestUrlFromReferenceAutolink(href) + : href; const pullRequestPreviewTarget = pullRequestCandidateUrl ? resolvePullRequestPreviewTarget({ environmentId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 62223ba82096..ec4fe2a5511d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -102,7 +102,7 @@ import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/mod import { projectScriptCwd, resolveProjectScripts } from "@t3tools/shared/projectScripts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 64b2b4edf7c9..53a39007574e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -22,7 +22,7 @@ import { } from "@t3tools/client-runtime/operations/projects"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; -import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 4072f3aad541..d9bf14748258 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,5 +1,5 @@ import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 4c1059eaeeb4..c4577f64fe51 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -4,7 +4,7 @@ import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities import { threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import type { ContextMenuItem, EnvironmentId, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 00710286a6bd..7a66512ccadc 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -27,13 +27,12 @@ import { resolveThreadPullRequestChains, type ThreadPullRequestBadge, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { useRender } from "@base-ui/react/use-render"; import { type ReactNode, type AnimationEvent, type MouseEvent, type ReactElement } from "react"; import { cn } from "../lib/utils"; import { revealThreadTerminal } from "../lib/revealThreadTerminal"; -import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -120,7 +119,7 @@ export function useLinkedThreadPullRequest( } function linkedPullRequestReference(fallback: ThreadLinkedPullRequest) { - const host = parseChangeRequestUrl(fallback.url)?.host; + const host = sourceControlClients.parseChangeRequestUrl(fallback.url)?.link.host; return { ...fallback, ...(host === undefined ? {} : { host }) }; } @@ -150,7 +149,7 @@ export function linkedPullRequestSnapshotStatus( export { resolveThreadPullRequestBadge, type ThreadPullRequestBadge, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; export interface ThreadPullRequestBadgePresentation { readonly Icon: PullRequestGlyphIcon; diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index 8dfb4623801f..f08251b18425 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -5,11 +5,12 @@ import { resolveThreadPullRequestChains, threadPullRequestKeyOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { Minus, Plus } from "lucide"; import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } from "react"; -import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { findProjectOnChangeRequestHost } from "~/lib/openPullRequestLink"; import { useProjects } from "~/state/entities"; import { threadEnvironment } from "~/state/threads"; @@ -34,9 +35,9 @@ function ThreadDetailsPrLinkRow({ onStopWatching?: (() => void) | undefined; }) { const projects = useProjects(); - const parsed = parseChangeRequestUrl(link.url); + const parsed = sourceControlClients.parseChangeRequestUrl(link.url)?.link; const project = - parsed === null + parsed === undefined ? null : (findProjectOnChangeRequestHost( projects.filter((candidate) => candidate.environmentId === environmentId), diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx index 6e42b7257c3f..8706f374522f 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx @@ -1,7 +1,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; import { useRouter } from "@tanstack/react-router"; import * as Effect from "effect/Effect"; @@ -155,7 +155,7 @@ export function DesktopWebLinkCoordinator() { // delivered at launch would miss its project and fall through to the browser. Hold PR // links, and only those, until discovery settles; an ordinary link never waits for it. const hold = - openPullRequestsInApp && parseChangeRequestUrl(url) !== null + openPullRequestsInApp && sourceControlClients.parseChangeRequestUrl(url) !== null ? projectsSettled() : Promise.resolve(); void hold.then(() => { diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 0e3372b81a1a..f715ecef5153 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,4 +1,4 @@ -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links"; import { useAtomValue } from "@effect/atom-react"; import { usePullRequestStack } from "~/state/usePullRequestStack"; import { @@ -53,7 +53,6 @@ import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings import { primaryServerKeybindingsAtom } from "~/state/server"; import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions"; import { OFF_HEAD_PULL_REQUEST_CHECKOUT_TOAST } from "./pullRequestCheckoutToast"; -import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -252,7 +251,10 @@ function pullRequestStackReference( ) { return detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests ? null - : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host }; + : { + ...reference, + host: reference.host ?? sourceControlClients.parseChangeRequestUrl(detail.url)?.link.host, + }; } export function PullRequestDetailPanel({ @@ -305,7 +307,7 @@ export function PullRequestDetailPanel({ supportsThreadPullRequests, ); const loadingCheckoutCommand = loadingPullRequestCheckoutCommand(reference, repositoryIdentity); - const unavailableGitHubUrl = gitHubPullRequestBrowserUrl( + const unavailableHostUrl = projectChangeRequestUrl( repositoryIdentity, reference.repository, reference.number, @@ -469,7 +471,10 @@ export function PullRequestDetailPanel({ setSeenAutoMergeKey(autoMergeKey); if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); } - const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); + const repositoryUrl = + detail === null + ? null + : (sourceControlClients.parseChangeRequestUrl(detail.url)?.link.repositoryUrl ?? null); const hostClient = sourceControlClients.get(detail?.provider); const openOnHostText = openOnHostLabel(hostClient); const markdownContext = pullRequestMarkdownContext(hostClient, repositoryUrl, threadRef); @@ -483,7 +488,7 @@ export function PullRequestDetailPanel({ handoffSummary.number, handoffSummary.headBranch, detail?.headRepositoryNameWithOwner, - changeRequestRepositoryUrl(handoffSummary.url), + sourceControlClients.parseChangeRequestUrl(handoffSummary.url)?.link.repositoryUrl ?? null, ) : loadingCheckoutCommand; const branchRefsQuery = useEnvironmentQuery( @@ -1695,7 +1700,16 @@ export function PullRequestDetailPanel({ : { error: detailQuery.error })} refreshing={detailQuery.isPending} onRetry={refreshDetail} - {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})} + {...(unavailableHostUrl + ? { + host: { + url: unavailableHostUrl, + openLabel: openOnHostLabel( + sourceControlClients.get(repositoryIdentity?.provider), + ), + }, + } + : {})} /> ) : detail ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index f9dff552a95c..6b70218b91da 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -7,8 +7,8 @@ import { useState } from "react"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; -import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; @@ -49,7 +49,7 @@ function EnabledPullRequestThreadLinks({ display, onPickerOpenChange, }: PullRequestThreadLinksProps) { - const parsed = parseChangeRequestUrl(url); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link ?? null; const thread = useThreadShell(threadRef?.environmentId === environmentId ? threadRef : null); const currentThreadRef = thread === null ? null : threadRef; const linking = usePullRequestLinking(environmentId); diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx index 6c84b59fecf9..b207e5a048c9 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -10,13 +10,13 @@ export function PullRequestsUnavailableState({ error, onRetry, refreshing = false, - gitHubUrl, + host, }: { title?: string; error: string; onRetry?: () => void; refreshing?: boolean; - gitHubUrl?: string; + host?: { readonly url: string; readonly openLabel: string }; }) { return ( @@ -29,7 +29,7 @@ export function PullRequestsUnavailableState({ shows its message rather than trying to infer one from the failure text. */} {error} - {onRetry || gitHubUrl ? ( + {onRetry || host ? (
{onRetry ? ( ) : null}
diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index f52e5b7c4312..41455322e6ca 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -8,7 +8,7 @@ import { pullRequestListLines, resolveThreadPullRequestChains, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { ArrowUpRightIcon, EyeIcon, @@ -25,7 +25,8 @@ import { encodePullRequestDragPayload, } from "~/components/chat/composerMentionDrag"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { findProjectForChangeRequest, useOpenPrLink } from "~/lib/openPullRequestLink"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { cn } from "~/lib/utils"; import { useShortcutModifierState } from "~/shortcutModifierState"; import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities"; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index ede70552a7c6..dd29162fbde4 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -34,7 +34,7 @@ import { resolveThreadCurrentPullRequestLink, threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; import { reviewCommentContextId } from "~/lib/composerContextRecords"; @@ -1409,16 +1409,10 @@ export function resolveDisplayedPullRequestDetail(input: { return null; } if (input.reference.host === undefined) return input.cached; - try { - const url = new URL(input.cached.url); - const host = sourceControlClients.get(input.cached.provider).changeRequestUrlHost(url); - return (url.protocol === "https:" || url.protocol === "http:") && - host.toLowerCase() === input.reference.host.toLowerCase() - ? input.cached - : null; - } catch { - return null; - } + const link = sourceControlClients.parseChangeRequestUrl(input.cached.url)?.link; + return link !== undefined && (link.authority ?? link.host) === input.reference.host.toLowerCase() + ? input.cached + : null; } export function resolvePullRequestDetailChecks( diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index cfab5ca8b0c1..51d913f61b99 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -3,7 +3,7 @@ import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; import { requestCustomSnooze } from "../CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { diff --git a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts index 7edbd93a0e0c..255452cb2dd5 100644 --- a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts +++ b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts @@ -5,7 +5,7 @@ import { readPullRequestDetailSnapshot, resolvePullRequestReferenceHost, } from "../components/pullRequest/pullRequestDetail.logic"; -import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink"; +import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links"; import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore"; import { useProject } from "../state/entities"; import { pullRequestEnvironment } from "../state/pullRequests"; @@ -71,11 +71,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { : null; const fallbackUrl = reference ? (requestedReference?.url ?? - gitHubPullRequestBrowserUrl( - project?.repositoryIdentity, - reference.repository, - reference.number, - )) + projectChangeRequestUrl(project?.repositoryIdentity, reference.repository, reference.number)) : undefined; const detail = useEnvironmentQuery( reference && environmentId diff --git a/apps/web/src/hooks/usePullRequestLinking.test.ts b/apps/web/src/hooks/usePullRequestLinking.test.ts index 5cb7138f1d13..107cb0f56afd 100644 --- a/apps/web/src/hooks/usePullRequestLinking.test.ts +++ b/apps/web/src/hooks/usePullRequestLinking.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import { canLinkChangeRequest } from "./usePullRequestLinking"; -import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; describe("canLinkChangeRequest (#9435 / #9440)", () => { const nonGitProject = { id: "scratch-project" } as never; - const link = parseChangeRequestUrl("https://github.com/pingdotgg/t3code/pull/15111")!; + const link = sourceControlClients.parseChangeRequestUrl( + "https://github.com/pingdotgg/t3code/pull/15111", + )!.link; it("links a pasted pull request URL when the server tracks links on the thread, even with no matching project", () => { expect(canLinkChangeRequest("multiple", [nonGitProject], link)).toBe(true); diff --git a/apps/web/src/hooks/usePullRequestLinking.ts b/apps/web/src/hooks/usePullRequestLinking.ts index a9ee2163a8be..18c31cd3714d 100644 --- a/apps/web/src/hooks/usePullRequestLinking.ts +++ b/apps/web/src/hooks/usePullRequestLinking.ts @@ -17,13 +17,12 @@ import { import { threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; import { - findProjectForChangeRequest, - matchesLinkedPullRequestUrl, - parseChangeRequestUrl, + sourceControlClients, type ChangeRequestLink, -} from "~/lib/openPullRequestLink"; +} from "@t3tools/client-runtime/source-control-clients"; import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "~/rpc/atomRegistry"; @@ -62,8 +61,8 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); const environmentProjects = projects.filter((project) => project.environmentId === environmentId); const canLink = (url: string) => { - const parsed = parseChangeRequestUrl(url); - return parsed !== null && canLinkChangeRequest(mode, environmentProjects, parsed); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + return parsed !== undefined && canLinkChangeRequest(mode, environmentProjects, parsed); }; const isLinked = ( thread: { @@ -76,19 +75,23 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde if (mode !== "multiple") return ( thread.linkedPullRequest != null && - matchesLinkedPullRequestUrl(thread.linkedPullRequest, url) + sourceControlClients.isSameChangeRequest(thread.linkedPullRequest.url, url) ); - const parsed = parseChangeRequestUrl(url); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; return ( - parsed !== null && + parsed !== undefined && visibleThreadPullRequests(thread.pullRequests ?? []).some((entry) => threadPullRequestKeysEqual(entry, parsed), ) ); }; const changeLink = async (threadRef: ScopedThreadRef, url: string, linked: boolean) => { - const parsed = parseChangeRequestUrl(url); - if (parsed === null || threadRef.environmentId !== environmentId || (linked && !canLink(url))) + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + if ( + parsed === undefined || + threadRef.environmentId !== environmentId || + (linked && !canLink(url)) + ) throw new Error("The pull request is not available in this environment."); const legacyProject = findProjectForChangeRequest(environmentProjects, parsed); const mutation = planThreadPullRequestMutation({ diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index 7489d60006c3..4b0de9244c54 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -1,190 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - changeRequestRepositoryUrl, - findProjectForChangeRequest, - findProjectOnChangeRequestHost, - gitHubPullRequestBrowserUrl, - matchesLinkedPullRequestUrl, - parseChangeRequestUrl, - pullRequestCandidateUrlFromReferenceAutolink, -} from "./openPullRequestLink"; -import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; - -function repositoryIdentity( - provider: string, - canonicalKey: string, - remoteUrl: string, -): RepositoryIdentity { - return { - canonicalKey, - provider, - locator: { source: "git-remote", remoteName: "origin", remoteUrl }, - }; -} - -describe("gitHubPullRequestBrowserUrl", () => { - it("uses the requested GitHub repository instead of the project's default repository", () => { - const identity = repositoryIdentity( - "github", - "github.com/acme/default", - "https://github.com/acme/default.git", - ); - - expect(gitHubPullRequestBrowserUrl(identity, "acme/other", 42)).toBe( - "https://github.com/acme/other/pull/42", - ); - }); - - it("preserves a custom GitHub HTTP origin without its credentials", () => { - const identity = repositoryIdentity( - "github", - "github.acme.test/team/default", - "http://token@github.acme.test:8443/team/default.git", - ); - - expect(gitHubPullRequestBrowserUrl(identity, "platform/api", 7)).toBe( - "http://github.acme.test:8443/platform/api/pull/7", - ); - }); - - it.each([ - { - name: "SSH", - remoteUrl: "git@github.acme.test:team/default.git", - }, - { - name: "git protocol", - remoteUrl: "git://github.acme.test/team/default.git", - }, - ])("uses the normalized host for a $name remote", ({ remoteUrl }) => { - const identity = repositoryIdentity("github", "github.acme.test/team/default", remoteUrl); - - expect(gitHubPullRequestBrowserUrl(identity, "platform/api", 9)).toBe( - "https://github.acme.test/platform/api/pull/9", - ); - }); - - // regression: #8507 the browser fallback must not open a bogus URL - it("returns null for missing or invalid GitHub data", () => { - expect(gitHubPullRequestBrowserUrl(null, "acme/repository", 1)).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), - "acme", - 1, - ), - ).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), - "../repository", - 1, - ), - ).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), - "acme/repository", - 0, - ), - ).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "bad host/acme/repository", "not a remote"), - "acme/repository", - 1, - ), - ).toBeNull(); - }); - - // regression: #8507 the browser fallback for failed PR loads must only target GitHub - it.each(["gitlab", "bitbucket", "azure-devops", "unknown"])( - "does not build a fallback for %s", - (provider) => { - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity(provider, "github.com/acme/repository", "https://github.com/a/b"), - "acme/repository", - 1, - ), - ).toBeNull(); - }, - ); -}); - -describe("changeRequestRepositoryUrl", () => { - it("keeps pull-like segments inside nested GitLab repository paths", () => { - expect( - changeRequestRepositoryUrl( - "https://gitlab.example.test/group/pull/123/repo/-/merge_requests/42", - ), - ).toBe("https://gitlab.example.test/group/pull/123/repo"); - }); -}); - -describe("pullRequestCandidateUrlFromReferenceAutolink", () => { - it("turns GitHub's shared issue route into a pull request candidate", () => { - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://github.com/pingdotgg/t3code/issues/8600#issuecomment-1", - ), - ).toBe("https://github.com/pingdotgg/t3code/pull/8600#issuecomment-1"); - }); - - it("does not reinterpret other issue hosts or malformed references", () => { - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://gitlab.com/pingdotgg/t3code/-/issues/8600", - ), - ).toBeNull(); - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://github.com/pingdotgg/t3code/issues/not-a-number", - ), - ).toBeNull(); - }); -}); - -describe("matchesLinkedPullRequestUrl", () => { - const linkedPullRequest = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }; - - it("matches the same pull request without looking up its project", () => { - expect( - matchesLinkedPullRequestUrl( - linkedPullRequest, - "https://github.com/PingDotGG/T3Code/pull/42/files", - ), - ).toBe(true); - }); - - it.each([ - ["http://forge.example:3000/git/team/repo/pulls/42/files", true], - ["http://forge.example:4000/git/team/repo/pulls/42", false], - ["http://forge.example/git/team/repo/pulls/42", false], - ])("matches Forgejo links by web authority: %s", (url, expected) => { - expect( - matchesLinkedPullRequestUrl( - { ...linkedPullRequest, url: "http://forge.example:3000/git/team/repo/pulls/42" }, - url, - ), - ).toBe(expected); - }); - - it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => { - expect( - matchesLinkedPullRequestUrl( - { ...linkedPullRequest, url: "https://github.internal/team/repo/pulls/42" }, - "https://github.internal/team/repo/pull/42", - ), - ).toBe(false); - }); -}); +import { findProjectOnChangeRequestHost } from "./openPullRequestLink"; describe("findProjectOnChangeRequestHost", () => { const project = (id: string, identity: Record) => @@ -202,41 +18,6 @@ describe("findProjectOnChangeRequestHost", () => { name: "backend", }); - it.each([ - ["ssh.dev.azure.com/v3/org-a/project/web", "git@ssh.dev.azure.com:v3/org-a/project/web"], - [ - "vs-ssh.visualstudio.com/v3/org-a/project/web", - "git@vs-ssh.visualstudio.com:v3/org-a/project/web", - ], - [ - "org-a.visualstudio.com/defaultcollection/project/_git/web", - "https://org-a.visualstudio.com/DefaultCollection/Project/_git/Web", - ], - ["dev.azure.com/org-a/project/_git/web", "https://org-a@dev.azure.com/org-a/Project/_git/Web"], - ])("matches Azure browser references against %s", (canonicalKey, remoteUrl) => { - const checkout = project("azure", { - ...repositoryIdentity("azure-devops", canonicalKey, remoteUrl), - displayName: canonicalKey.split("/").slice(1).join("/"), - }); - const reference = parseChangeRequestUrl( - "https://dev.azure.com/org-a/project/_git/web/pullrequest/42", - )!; - expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout); - expect(findProjectOnChangeRequestHost([checkout], reference)).toBe(checkout); - expect( - findProjectOnChangeRequestHost([checkout], { - ...reference, - repository: "org-b/project/_git/web", - }), - ).toBeUndefined(); - expect( - findProjectOnChangeRequestHost([checkout], { - ...reference, - repository: "org-a/other-project/_git/web", - }), - ).toBeUndefined(); - }); - it("prefers the project checked out from the link's own repository", () => { expect( findProjectOnChangeRequestHost([frontend, backend], { @@ -247,59 +28,6 @@ describe("findProjectOnChangeRequestHost", () => { ).toBe(backend); }); - it("selects the Forgejo HTTP port for repository and host-level matches", () => { - const projects = [3000, 4000].map((port) => - project(`forgejo-${port}`, { - canonicalKey: "forge.example/git/team/repo", - provider: "forgejo", - displayName: "git/team/repo", - locator: { remoteUrl: `http://forge.example:${port}/git/team/repo.git` }, - }), - ); - const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!; - expect(findProjectForChangeRequest(projects, reference)).toBe(projects[1]); - expect(findProjectOnChangeRequestHost(projects, reference)).toBe(projects[1]); - expect( - findProjectOnChangeRequestHost(projects, { ...reference, repository: "git/team/other" }), - ).toBe(projects[1]); - expect(findProjectForChangeRequest([projects[0]!], reference)).toBeUndefined(); - expect(findProjectOnChangeRequestHost([projects[0]!], reference)).toBeUndefined(); - }); - - it("lets tea resolve the web port for Forgejo SSH remotes", () => { - const checkout = project("forgejo-ssh", { - canonicalKey: "forge.example/git/team/repo", - provider: "forgejo", - displayName: "git/team/repo", - locator: { remoteUrl: "git@forge.example:git/team/repo.git" }, - }); - const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!; - expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout); - const aliased = project("forgejo-alias", { - canonicalKey: "ssh.forge.example/team/repo", - provider: "forgejo", - displayName: "team/repo", - locator: { remoteUrl: "git@ssh.forge.example:team/repo.git" }, - webUrl: "http://forge.example:4000/git/team/repo", - }); - expect(findProjectForChangeRequest([aliased], reference)).toBe(aliased); - expect(findProjectOnChangeRequestHost([aliased], reference)).toBe(aliased); - expect( - findProjectOnChangeRequestHost([aliased], { ...reference, repository: "git/team/other" }), - ).toBe(aliased); - for (const url of [ - "http://other.example:4000/git/team/repo/pulls/42", - "http://forge.example:3000/git/team/repo/pulls/42", - ]) { - const other = parseChangeRequestUrl(url)!; - expect(findProjectForChangeRequest([aliased], other)).toBeUndefined(); - expect(findProjectOnChangeRequestHost([aliased], other)).toBeUndefined(); - } - const otherMount = parseChangeRequestUrl("http://forge.example:4000/other/team/repo/pulls/42")!; - expect(findProjectForChangeRequest([aliased], otherMount)).toBeUndefined(); - expect(findProjectOnChangeRequestHost([aliased], otherMount)).toBeUndefined(); - }); - it("lends any project on the host to a repository nobody has checked out", () => { expect( findProjectOnChangeRequestHost([frontend], { @@ -310,45 +38,3 @@ describe("findProjectOnChangeRequestHost", () => { ).toBe(frontend); }); }); - -describe("findProjectForChangeRequest", () => { - const project = (identity: Record) => - ({ id: "p1", repositoryIdentity: identity }) as never; - - it("matches a nested GitLab group by the whole path below the host", () => { - const projects = [ - project({ - canonicalKey: "gitlab.com/t3tools/platform/t3code", - provider: "gitlab", - displayName: "t3tools/platform/t3code", - owner: "t3tools", - name: "t3code", - }), - ]; - expect( - findProjectForChangeRequest(projects, { - host: "gitlab.com", - repository: "t3tools/platform/t3code", - number: 42, - }), - ).toBe(projects[0]); - }); - - it("claims nothing for a lookalike host, which is what keeps a link a link", () => { - const projects = [ - project({ - canonicalKey: "github.com/pingdotgg/t3code", - provider: "github", - owner: "pingdotgg", - name: "t3code", - }), - ]; - expect( - findProjectForChangeRequest(projects, { - host: "github.com-evil.test", - repository: "pingdotgg/t3code", - number: 1, - }), - ).toBeUndefined(); - }); -}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index ce5fb92778b9..0d3361e73de2 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,10 +1,16 @@ -import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent } from "react"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; +import { + findProjectForChangeRequest, + resolvePullRequestPreviewTarget, +} from "@t3tools/client-runtime/change-request-links"; +import { + sourceControlClients, + type ChangeRequestLink, +} from "@t3tools/client-runtime/source-control-clients"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { useOpenLink } from "../browser/useOpenLink"; @@ -18,58 +24,6 @@ import { readProjects, useProjects } from "../state/entities"; import { environmentServerConfigsAtom, serverEnvironment } from "../state/server"; import { usePrimaryEnvironmentId } from "../state/environments"; -export { - parseChangeRequestUrl, - type ChangeRequestLink, - gitHubPullRequestBrowserUrl, - pullRequestCandidateUrlFromReferenceAutolink, - matchesLinkedPullRequestUrl, - changeRequestRepositoryUrl, -} from "@t3tools/shared/changeRequestUrl"; - -export function findProjectForChangeRequest( - projects: ReadonlyArray, - link: ChangeRequestLink, -): EnvironmentProject | undefined { - return projects.find((project) => { - const identity = project.repositoryIdentity; - return ( - identity?.provider !== undefined && - sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link) - ); - }); -} - -export function resolvePullRequestPreviewTarget({ - environmentId, - projects, - pullRequestsEnabled, - url, -}: { - environmentId: EnvironmentId | null; - projects: ReadonlyArray; - pullRequestsEnabled: boolean; - url: string; -}): { environmentId: EnvironmentId; input: PullRequestRef } | null { - if (!pullRequestsEnabled || environmentId === null) return null; - const parsed = parseChangeRequestUrl(url); - if (parsed === null) return null; - const project = findProjectForChangeRequest( - projects.filter((candidate) => candidate.environmentId === environmentId), - parsed, - ); - if (project === undefined) return null; - return { - environmentId, - input: { - projectId: project.id, - host: parsed.authority ?? parsed.host, - repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, - number: parsed.number, - }, - }; -} - export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) { const projects = useProjects(); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); @@ -96,7 +50,7 @@ export function findProjectOnChangeRequestHost( }); } -export function shouldOpenPullRequestExternally( +function shouldOpenPullRequestExternally( event: Pick, "metaKey" | "ctrlKey"> & Partial, "button">>, ): boolean { @@ -127,8 +81,8 @@ export function useOpenChangeRequestLink( const allProjects = readProjects(); const resolvedThreadRef = targetThreadRef ?? threadRef; const resolvedPanelRef = panelRef ?? resolvedThreadRef; - const parsed = parseChangeRequestUrl(targetUrl); - if (parsed === null) return false; + const parsed = sourceControlClients.parseChangeRequestUrl(targetUrl)?.link; + if (parsed === undefined) return false; const reads = (environmentId: string) => serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests === true; diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 8757c4b6caf0..caf610184557 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -19,7 +19,7 @@ import type { import { threadPullRequestKeyOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useLayoutEffect } from "react"; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 7bec2b2c0831..81a91d6057aa 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -367,6 +367,18 @@ "types": "./src/pullRequestLinkInput.ts", "default": "./src/pullRequestLinkInput.ts" }, + "./change-request-links": { + "types": "./src/changeRequestLinks.ts", + "default": "./src/changeRequestLinks.ts" + }, + "./thread-pull-requests": { + "types": "./src/threadPullRequests.ts", + "default": "./src/threadPullRequests.ts" + }, + "./thread-reference": { + "types": "./src/threadReference.ts", + "default": "./src/threadReference.ts" + }, "./thread-pull-request-compatibility": { "types": "./src/threadPullRequestCompatibility.ts", "default": "./src/threadPullRequestCompatibility.ts" diff --git a/packages/client-runtime/src/changeRequestLinks.test.ts b/packages/client-runtime/src/changeRequestLinks.test.ts new file mode 100644 index 000000000000..f5d39305da2e --- /dev/null +++ b/packages/client-runtime/src/changeRequestLinks.test.ts @@ -0,0 +1,104 @@ +import { EnvironmentId, ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + findProjectForChangeRequest, + projectChangeRequestUrl, + resolvePullRequestPreviewTarget, +} from "./changeRequestLinks.ts"; + +describe("findProjectForChangeRequest", () => { + const project = (identity: Record) => + ({ id: "p1", repositoryIdentity: identity }) as never; + + it("matches a nested GitLab group by the whole path below the host", () => { + const projects = [ + project({ + canonicalKey: "gitlab.com/t3tools/platform/t3code", + provider: "gitlab", + displayName: "t3tools/platform/t3code", + owner: "t3tools", + name: "t3code", + }), + ]; + expect( + findProjectForChangeRequest(projects, { + host: "gitlab.com", + repository: "t3tools/platform/t3code", + number: 42, + }), + ).toBe(projects[0]); + }); + + it("claims nothing for a lookalike host, which is what keeps a link a link", () => { + const projects = [ + project({ + canonicalKey: "github.com/pingdotgg/t3code", + provider: "github", + owner: "pingdotgg", + name: "t3code", + }), + ]; + expect( + findProjectForChangeRequest(projects, { + host: "github.com-evil.test", + repository: "pingdotgg/t3code", + number: 1, + }), + ).toBeUndefined(); + }); +}); + +describe("resolvePullRequestPreviewTarget", () => { + const environmentId = EnvironmentId.make("env-1"); + const project = { + id: ProjectId.make("project-1"), + environmentId, + repositoryIdentity: { + canonicalKey: "github.com/acme/web", + provider: "github", + displayName: "acme/web", + locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@github.com:acme/web" }, + } satisfies RepositoryIdentity, + }; + + it("previews a pull request only under the project that holds its repository", () => { + const target = (url: string) => + resolvePullRequestPreviewTarget({ + environmentId, + projects: [project], + pullRequestsEnabled: true, + url, + }); + expect(target("https://github.com/acme/web/pull/7")).toEqual({ + environmentId, + input: { projectId: project.id, host: "github.com", repository: "acme/web", number: 7 }, + }); + expect(target("https://github.com/acme/other/pull/7")).toBeNull(); + }); +}); + +describe("projectChangeRequestUrl", () => { + const identity = (provider: string): RepositoryIdentity => ({ + canonicalKey: "github.com/acme/default", + provider, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:acme/default", + }, + }); + + it("builds the requested repository's URL on the project's host", () => { + expect(projectChangeRequestUrl(identity("github"), "acme/other", 42)).toBe( + "https://github.com/acme/other/pull/42", + ); + }); + + it("builds nothing without a project host that knows its URL shape", () => { + expect(projectChangeRequestUrl(null, "acme/repository", 1)).toBeNull(); + expect(projectChangeRequestUrl(identity("unknown"), "acme/repository", 1)).toBeNull(); + const { provider: _provider, ...withoutProvider } = identity("github"); + expect(projectChangeRequestUrl(withoutProvider, "acme/repository", 1)).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/changeRequestLinks.ts b/packages/client-runtime/src/changeRequestLinks.ts new file mode 100644 index 000000000000..36c9097c951e --- /dev/null +++ b/packages/client-runtime/src/changeRequestLinks.ts @@ -0,0 +1,77 @@ +import type { + EnvironmentId, + ProjectId, + PullRequestRef, + RepositoryIdentity, +} from "@t3tools/contracts"; +import type { ChangeRequestLink } from "@t3tools/source-control-core/client/definition"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; + +import { sourceControlClients } from "./sourceControlClients.ts"; + +interface ChangeRequestProject { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly repositoryIdentity?: RepositoryIdentity | null | undefined; +} + +/** The project whose own repository holds the change request, not just one on its host. */ +export function findProjectForChangeRequest( + projects: ReadonlyArray, + link: ChangeRequestLink, +): Project | undefined { + return projects.find((project) => { + const identity = project.repositoryIdentity; + return ( + identity?.provider !== undefined && + sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link) + ); + }); +} + +export function resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled, + url, +}: { + environmentId: EnvironmentId | null; + projects: ReadonlyArray; + pullRequestsEnabled: boolean; + url: string; +}): { environmentId: EnvironmentId; input: PullRequestRef } | null { + if (!pullRequestsEnabled || environmentId === null) return null; + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + if (parsed === undefined) return null; + const project = findProjectForChangeRequest( + projects.filter((candidate) => candidate.environmentId === environmentId), + parsed, + ); + if (project === undefined) return null; + return { + environmentId, + input: { + projectId: project.id, + host: parsed.authority ?? parsed.host, + repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, + number: parsed.number, + }, + }; +} + +/** The project host's browser URL for one of its change requests, before its detail loads. */ +export function projectChangeRequestUrl( + identity: RepositoryIdentity | null | undefined, + repository: string, + number: number, +): string | null { + if (identity?.provider === undefined) return null; + const host = identity.canonicalKey.split("/")[0]; + if (!host) return null; + return sourceControlClients.get(identity.provider).changeRequestUrl({ + host, + repository, + number, + remoteUrl: identity.locator.remoteUrl.trim(), + }); +} diff --git a/packages/client-runtime/src/promptLinks.ts b/packages/client-runtime/src/promptLinks.ts index 4e4c1214180e..175b3aa5cb6f 100644 --- a/packages/client-runtime/src/promptLinks.ts +++ b/packages/client-runtime/src/promptLinks.ts @@ -12,7 +12,7 @@ import { threadPullRequestKeyOf, threadPullRequestsOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "./threadPullRequests.ts"; const repositoryKey = (repository: PromptLinkRepository) => `github.com/${repository.owner}/${repository.name}`.toLowerCase(); diff --git a/packages/client-runtime/src/pullRequestLinkInput.ts b/packages/client-runtime/src/pullRequestLinkInput.ts index a72701adc903..c897dc458878 100644 --- a/packages/client-runtime/src/pullRequestLinkInput.ts +++ b/packages/client-runtime/src/pullRequestLinkInput.ts @@ -1,5 +1,4 @@ import { pullRequestHostOf, type RepositoryIdentity } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { sourceControlClients } from "./sourceControlClients.ts"; @@ -75,12 +74,12 @@ export function resolveLinkPullRequestInput(input: { readonly hasProject: (reference: ResolvedLink) => boolean; }): { link: ResolvedLink } | { error: string } | null { const parsed = - parseChangeRequestUrl(input.reference.trim()) !== null + sourceControlClients.parseChangeRequestUrl(input.reference.trim()) !== null ? input.reference.trim() : parsePullRequestReference(input.reference); if (parsed === null) return null; - const url = parseChangeRequestUrl(parsed); - if (url !== null) { + const url = sourceControlClients.parseChangeRequestUrl(parsed)?.link; + if (url !== undefined) { if (!input.hasProject({ ...url, url: parsed })) { return { error: `No project in this environment can read ${url.host}/${url.repository}.` }; } @@ -97,11 +96,17 @@ export function resolveLinkPullRequestInput(input: { }; } const webUrl = input.project.webUrl(number); - const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl); - if (webUrl === null || webReference === null) { + const webReference = + webUrl === null ? undefined : sourceControlClients.parseChangeRequestUrl(webUrl)?.link; + if (webUrl === null || webReference === undefined) { return { error: "Paste a full URL; this project's host has no known pull request URL." }; } return { - link: { ...webReference, url: webUrl }, + link: { + host: webReference.host, + repository: webReference.repository, + number: webReference.number, + url: webUrl, + }, }; } diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts new file mode 100644 index 000000000000..4a646bc9caba --- /dev/null +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -0,0 +1,240 @@ +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { sourceControlClients } from "./sourceControlClients.ts"; +import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; + +const parse = (url: string) => { + const parsed = sourceControlClients.parseChangeRequestUrl(url); + if (parsed === null) return null; + const { repositoryUrl: _repositoryUrl, ...link } = parsed.link; + return link; +}; +const kindOf = (url: string) => sourceControlClients.parseChangeRequestUrl(url)?.definition.kind; + +describe("sourceControlClients.parseChangeRequestUrl", () => { + it("reads a GitHub pull request, lower-casing the repository", () => { + expect(parse("https://github.com/T3Tools/T3Code/pull/123")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + }); + + it("reads a GitCafe pull request at its canonical /pull/ path and the older /pulls/ path", () => { + for (const path of ["pull", "pulls"]) { + const url = `https://git.cafe/PingDotGG/T3Code/${path}/23897`; + expect(parse(url), path).toEqual({ + host: "git.cafe", + repository: "pingdotgg/t3code", + number: 23897, + }); + expect(kindOf(url), path).toBe("gitcafe"); + } + expect(parse("https://staging.git.cafe/team/repo/pull/7/files")).toEqual({ + host: "staging.git.cafe", + repository: "team/repo", + number: 7, + }); + }); + + it("reads Forgejo URLs even when the hostname contains github", () => { + expect(parse("https://github.internal/team/repo/pulls/7")).toEqual({ + host: "github.internal", + authority: "github.internal", + repository: "team/repo", + number: 7, + }); + }); + + it("reads a GitHub pull request on any host, so GitHub Enterprise works on its own name", () => { + expect(parse("https://code.github.example.com/acme/web/pull/42")).toEqual({ + host: "code.github.example.com", + repository: "acme/web", + number: 42, + }); + expect(kindOf("https://scm.example.test/acme/web/pull/42")).toBe("github"); + expect( + sourceControlClients.changeRequestUrlFromReferenceAutolink( + "https://code.github.example.com/acme/web/issues/42", + ), + ).toBe("https://code.github.example.com/acme/web/pull/42"); + }); + + it("reads a GitLab merge request on any host, nested groups and all", () => { + expect(parse("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42")).toEqual({ + host: "gitlab.com", + repository: "t3tools/platform/t3code", + number: 42, + }); + expect(parse("https://code.acme.test/team/project/-/merge_requests/9")).toEqual({ + host: "code.acme.test", + repository: "team/project", + number: 9, + }); + }); + + it("reads Bitbucket and both Azure DevOps URL forms", () => { + expect(parse("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({ + host: "bitbucket.org", + repository: "workspace/repo", + number: 5, + }); + expect(parse("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17")).toEqual({ + host: "dev.azure.com", + repository: "acme/platform/_git/t3code", + number: 17, + }); + expect(parse("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17")).toEqual({ + host: "acme.visualstudio.com", + repository: "platform/_git/t3code", + number: 17, + }); + }); + + it("reads Azure DevOps Server links and keeps servers on different ports distinct", () => { + for (const host of ["scm.example.test", "git.cafe", "code.bitbucket.example.test"]) { + const repository = "tfs/collection/project/_git/repo"; + const linked = { + projectId: ProjectId.make("project-1"), + host, + repository, + number: 123, + url: `https://${host.toUpperCase()}:8443/tfs/Collection/Project/_git/Repo/pullrequest/123`, + }; + const other = { ...linked, url: linked.url.replace(":8443/", ":9443/") }; + expect.soft(parse(linked.url), host).toEqual({ + host, + authority: `${host}:8443`, + repository, + number: 123, + }); + expect + .soft( + sourceControlClients.isSameChangeRequest(linked.url, `${linked.url}?view=files`), + host, + ) + .toBe(true); + expect + .soft(sourceControlClients.isSameChangeRequest(linked.url, other.url), host) + .toBe(false); + expect.soft([linked, other].map(normalizeThreadPullRequestKey), host).toEqual([ + { host: `${host}:8443`, repository, number: 123 }, + { host: `${host}:9443`, repository, number: 123 }, + ]); + } + }); + + it("survives trailing segments, a trailing slash and a query string", () => { + expect(parse("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + expect(parse("https://github.com/t3tools/t3code/pull/123/")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + }); + + it("claims nothing it cannot be sure of", () => { + for (const link of [ + "https://github.com/t3tools/t3code/issues/123", + "https://git.cafe/pingdotgg/t3code/issues/23897", + "https://git.cafe/pingdotgg/t3code/pull/abc", + "https://git.cafe/pingdotgg/t3code", + "https://github.com/t3tools/t3code/commit/0a1b2c3", + "https://github.com/t3tools/t3code", + "https://github.com/t3tools/t3code/pull/abc", + "https://gitlab.com/t3tools/t3code/-/issues/12", + "https://blog.example.test/2026/updates/archive/pull/3", + "https://scm.example.test/tfs/Collection/Project/_git/Repo", + "https://scm.example.test/tfs/Collection/Project/pullrequest/123", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992", + "ftp://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123", + "javascript:alert(1)//github.com/t3tools/t3code/pull/1", + "not a url", + ]) { + expect(parse(link), link).toBeNull(); + } + }); +}); + +describe("sourceControlClients.isSameChangeRequest", () => { + const linked = "https://github.com/pingdotgg/t3code/pull/42"; + + it("matches the same pull request whatever its case or trailing path", () => { + expect( + sourceControlClients.isSameChangeRequest( + linked, + "https://github.com/PingDotGG/T3Code/pull/42/files", + ), + ).toBe(true); + }); + + it.each([ + ["http://forge.example:3000/git/team/repo/pulls/42/files", true], + ["http://forge.example:4000/git/team/repo/pulls/42", false], + ["http://forge.example/git/team/repo/pulls/42", false], + ])("matches Forgejo links by web authority: %s", (url, expected) => { + expect( + sourceControlClients.isSameChangeRequest( + "http://forge.example:3000/git/team/repo/pulls/42", + url, + ), + ).toBe(expected); + }); + + it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => { + expect( + sourceControlClients.isSameChangeRequest( + "https://github.internal/team/repo/pulls/42", + "https://github.internal/team/repo/pull/42", + ), + ).toBe(false); + }); +}); + +describe("change request URLs built from a parsed link", () => { + const sibling = (url: string, number: number) => { + const parsed = sourceControlClients.parseChangeRequestUrl(url)!; + return parsed.definition.changeRequestUrl({ + host: parsed.link.host, + repository: parsed.link.repository, + number, + webUrl: parsed.link.repositoryUrl, + }); + }; + + it.each([ + [ + "http://git.example.test:3000/team/repo/pulls/42/files", + "http://git.example.test:3000/team/repo/pulls/43", + ], + ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"], + [ + "https://dev.azure.com/acme/project/_git/web/pullrequest/42?view=files", + "https://dev.azure.com/acme/project/_git/web/pullrequest/43", + ], + ])("builds a canonical sibling of %s", (url, expected) => { + expect(sibling(url, 43)).toBe(expected); + }); + + it.each([ + ["ssh.dev.azure.com", "v3/org/project/web"], + ["org.visualstudio.com", "defaultcollection/project/_git/web"], + ["dev.azure.com", "org/project/_git/web"], + ])("reads back the Azure browser URL built from the remote %s/%s", (host, repository) => { + const url = sourceControlClients + .get("azure-devops") + .changeRequestUrl({ host, repository, number: 42 }); + expect(parse(url!)).toEqual({ + host: "dev.azure.com", + repository: "org/project/_git/web", + number: 42, + }); + }); +}); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index 55c2cde6456e..7faf3abcc903 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -8,7 +8,9 @@ import type { ServerSettings } from "@t3tools/contracts"; export { UNKNOWN_SOURCE_CONTROL_CLIENT, + type ChangeRequestLink, type ChangeRequestTerminology, + type ParsedChangeRequestUrl, type SourceControlClientDefinition, } from "@t3tools/source-control-core/client/definition"; import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; @@ -19,8 +21,9 @@ import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; /** * GitHub comes first because a repository that has not reported its host yet reads as GitHub, * which is what clients have always shown there. A change request URL goes to the first host - * whose shape it has, so GitCafe, which claims `/pulls/` only on its own hostnames, comes before - * Forgejo, which claims it everywhere. + * whose shape it has, so GitCafe, which claims `/pull/` and `/pulls/` only on its own hostnames, + * claims before GitHub and Forgejo, which claim them everywhere. The server's + * `sourceControl/sourceControlClients.ts` keeps the same order. */ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ GitHub.definition, @@ -33,6 +36,7 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ export const sourceControlClients = makeSourceControlClientRegistry( BUILT_IN_SOURCE_CONTROL_CLIENTS, + { claimChangeRequestUrlsFirst: [GitCafe.definition.kind] }, ); export type { GitHubSettings } from "@t3tools/source-control-github/client/definition"; diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index ad011530b5d6..2474f3852efc 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -1,5 +1,5 @@ import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadPullRequestsOf } from "../threadPullRequests.ts"; import type { ThreadLinkedPullRequest, EnvironmentId, diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 491d158700f2..506afe1394dd 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -1,4 +1,4 @@ -import { applyThreadPullRequestLinkUpdate } from "@t3tools/shared/threadPullRequests"; +import { applyThreadPullRequestLinkUpdate } from "../threadPullRequests.ts"; import type { OrchestrationV2DomainEvent, OrchestrationV2Run, diff --git a/packages/client-runtime/src/threadPullRequestCompatibility.ts b/packages/client-runtime/src/threadPullRequestCompatibility.ts index 922fc119c993..82915bd6deed 100644 --- a/packages/client-runtime/src/threadPullRequestCompatibility.ts +++ b/packages/client-runtime/src/threadPullRequestCompatibility.ts @@ -4,7 +4,7 @@ import type { ThreadId, ThreadPullRequestKey, } from "@t3tools/contracts"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; type LinkingCapabilities = Pick< ExecutionEnvironmentCapabilities, diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/client-runtime/src/threadPullRequests.test.ts similarity index 100% rename from packages/shared/src/threadPullRequests.test.ts rename to packages/client-runtime/src/threadPullRequests.test.ts diff --git a/packages/client-runtime/src/threadPullRequests.ts b/packages/client-runtime/src/threadPullRequests.ts new file mode 100644 index 000000000000..c4443728930e --- /dev/null +++ b/packages/client-runtime/src/threadPullRequests.ts @@ -0,0 +1,28 @@ +import { makeThreadPullRequests } from "@t3tools/source-control-core/client/threadPullRequests"; + +import { sourceControlClients } from "./sourceControlClients.ts"; + +export { + threadPullRequestSearchTerms, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +export { + pullRequestListLines, + type PullRequestListLine, + type ThreadCurrentPullRequest, + type ThreadPullRequestBadge, + type ThreadPullRequestChain, +} from "@t3tools/source-control-core/client/threadPullRequests"; + +export const { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadCurrentPullRequest, + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestChains, + resolveThreadPullRequestBadge, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, +} = makeThreadPullRequests(sourceControlClients); diff --git a/packages/shared/src/threadReference.test.ts b/packages/client-runtime/src/threadReference.test.ts similarity index 100% rename from packages/shared/src/threadReference.test.ts rename to packages/client-runtime/src/threadReference.test.ts diff --git a/packages/shared/src/threadReference.ts b/packages/client-runtime/src/threadReference.ts similarity index 100% rename from packages/shared/src/threadReference.ts rename to packages/client-runtime/src/threadReference.ts diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 57bbc2db2b6e..146fffde2c79 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -16,7 +16,7 @@ import { } from "@t3tools/shared/t3McpToolPresentation"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { formatTokens } from "@t3tools/shared/usageFormat"; import { classifyToolActivity } from "@t3tools/shared/toolActivity"; @@ -184,7 +184,10 @@ function resolveT3McpToolPresentation( const payload = asRecord(data); const input = asRecord(payload?.arguments) ?? asRecord(payload?.input) ?? asRecord(payload?.rawInput); - const urlTarget = typeof input?.url === "string" ? parseChangeRequestUrl(input.url) : null; + const urlTarget = + typeof input?.url === "string" + ? sourceControlClients.parseChangeRequestUrl(input.url)?.link + : null; const number = urlTarget?.number ?? input?.number; const target = actionKind !== undefined && diff --git a/packages/shared/package.json b/packages/shared/package.json index 0254443e1b46..3fe44db4f345 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -239,10 +239,6 @@ "types": "./src/threadTranscript.ts", "import": "./src/threadTranscript.ts" }, - "./threadReference": { - "types": "./src/threadReference.ts", - "import": "./src/threadReference.ts" - }, "./paletteSearch": { "types": "./src/paletteSearch.ts", "import": "./src/paletteSearch.ts" @@ -251,10 +247,6 @@ "types": "./src/threadPullRequests.ts", "import": "./src/threadPullRequests.ts" }, - "./changeRequestUrl": { - "types": "./src/changeRequestUrl.ts", - "import": "./src/changeRequestUrl.ts" - }, "./composerTrigger": { "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts deleted file mode 100644 index 0c7a469753ee..000000000000 --- a/packages/shared/src/changeRequestUrl.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { ProjectId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - changeRequestUrlFor, - matchesLinkedPullRequestUrl, - parseChangeRequestUrl, - pullRequestCandidateUrlFromReferenceAutolink, - siblingPullRequestUrl, -} from "./changeRequestUrl.ts"; -import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; - -describe("parseChangeRequestUrl", () => { - it("reads a GitHub pull request, lower-casing the repository", () => { - expect(parseChangeRequestUrl("https://github.com/T3Tools/T3Code/pull/123")).toEqual({ - host: "github.com", - repository: "t3tools/t3code", - number: 123, - }); - }); - - it("reads a GitCafe pull request at its canonical /pull/ path and the older /pulls/ path", () => { - for (const path of ["pull", "pulls"]) { - expect( - parseChangeRequestUrl(`https://git.cafe/PingDotGG/T3Code/${path}/23897`), - path, - ).toEqual({ host: "git.cafe", repository: "pingdotgg/t3code", number: 23897 }); - } - expect(parseChangeRequestUrl("https://staging.git.cafe/team/repo/pull/7/files")).toEqual({ - host: "staging.git.cafe", - repository: "team/repo", - number: 7, - }); - }); - - it("reads Forgejo URLs even when the hostname contains github", () => { - expect(parseChangeRequestUrl("https://github.internal/team/repo/pulls/7")).toEqual({ - host: "github.internal", - authority: "github.internal", - repository: "team/repo", - number: 7, - }); - }); - - it("reads a supported GitHub host with a middle DNS label", () => { - expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({ - host: "code.github.example.com", - repository: "acme/web", - number: 42, - }); - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://code.github.example.com/acme/web/issues/42", - ), - ).toBe("https://code.github.example.com/acme/web/pull/42"); - }); - - it("reads a GitLab merge request on any host, nested groups and all", () => { - expect( - parseChangeRequestUrl("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42"), - ).toEqual({ host: "gitlab.com", repository: "t3tools/platform/t3code", number: 42 }); - expect(parseChangeRequestUrl("https://code.acme.test/team/project/-/merge_requests/9")).toEqual( - { host: "code.acme.test", repository: "team/project", number: 9 }, - ); - }); - - it("reads Bitbucket and both Azure DevOps URL forms", () => { - expect(parseChangeRequestUrl("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({ - host: "bitbucket.org", - repository: "workspace/repo", - number: 5, - }); - expect( - parseChangeRequestUrl("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17"), - ).toEqual({ host: "dev.azure.com", repository: "acme/platform/_git/t3code", number: 17 }); - expect( - parseChangeRequestUrl("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"), - ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 }); - }); - - it("reads Azure DevOps Server links and keeps servers on different ports distinct", () => { - for (const host of ["scm.example.test", "git.cafe", "code.bitbucket.example.test"]) { - const repository = "tfs/collection/project/_git/repo"; - const linked = { - projectId: ProjectId.make("project-1"), - host, - repository, - number: 123, - url: `https://${host.toUpperCase()}:8443/tfs/Collection/Project/_git/Repo/pullrequest/123`, - }; - const other = { ...linked, url: linked.url.replace(":8443/", ":9443/") }; - expect.soft(parseChangeRequestUrl(linked.url), host).toEqual({ - host, - authority: `${host}:8443`, - repository, - number: 123, - }); - expect.soft(matchesLinkedPullRequestUrl(linked, `${linked.url}?view=files`), host).toBe(true); - expect.soft(matchesLinkedPullRequestUrl(linked, other.url), host).toBe(false); - expect.soft([linked, other].map(normalizeThreadPullRequestKey), host).toEqual([ - { host: `${host}:8443`, repository, number: 123 }, - { host: `${host}:9443`, repository, number: 123 }, - ]); - } - }); - - it("survives trailing segments, a trailing slash and a query string", () => { - expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({ - host: "github.com", - repository: "t3tools/t3code", - number: 123, - }); - expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/")).toEqual({ - host: "github.com", - repository: "t3tools/t3code", - number: 123, - }); - }); - - it("claims nothing it cannot be sure of", () => { - for (const link of [ - "https://github.com/t3tools/t3code/issues/123", - "https://git.cafe/pingdotgg/t3code/issues/23897", - "https://git.cafe/pingdotgg/t3code/pull/abc", - "https://git.cafe/pingdotgg/t3code", - "https://github.com/t3tools/t3code/commit/0a1b2c3", - "https://github.com/t3tools/t3code", - "https://github.com/t3tools/t3code/pull/abc", - "https://gitlab.com/t3tools/t3code/-/issues/12", - "https://blog.example.test/2026/updates/pull/3", - "https://scm.example.test/tfs/Collection/Project/_git/Repo", - "https://scm.example.test/tfs/Collection/Project/pullrequest/123", - "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc", - "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0", - "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992", - "ftp://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123", - "javascript:alert(1)//github.com/t3tools/t3code/pull/1", - "not a url", - ]) { - expect(parseChangeRequestUrl(link), link).toBeNull(); - } - }); -}); - -describe("siblingPullRequestUrl", () => { - it.each([ - [ - "http://git.example.test:3000/team/repo/pulls/42/files", - "http://git.example.test:3000/team/repo/pulls/43", - ], - ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"], - [ - "https://dev.azure.com/acme/project/_git/web/pullrequest/42?view=files", - "https://dev.azure.com/acme/project/_git/web/pullrequest/43", - ], - ])("builds a canonical sibling of %s", (url, expected) => { - expect(siblingPullRequestUrl(url, 43)).toBe(expected); - }); -}); - -describe("changeRequestUrlFor", () => { - it("preserves the origin when the Forgejo host already contains its port", () => { - expect( - changeRequestUrlFor( - "forgejo", - "forge.example:3000", - "team/repo", - 42, - "http://forge.example:3000/team/repo.git", - ), - ).toBe("http://forge.example:3000/team/repo/pulls/42"); - }); - - it.each([ - ["http://forge.example:3000/git/owner/repo.git", "http://forge.example:3000"], - ["git@forge.example:git/owner/repo.git", "https://forge.example"], - ["http://other.example:3000/git/owner/repo.git", "https://forge.example"], - ])("preserves the matching Forgejo web origin from %s", (remoteUrl, origin) => { - expect(changeRequestUrlFor("forgejo", "forge.example", "git/owner/repo", 42, remoteUrl)).toBe( - `${origin}/git/owner/repo/pulls/42`, - ); - }); - - it.each([ - ["ssh.dev.azure.com", "v3/org/project/web"], - ["org.visualstudio.com", "defaultcollection/project/_git/web"], - ["dev.azure.com", "org/project/_git/web"], - ])("builds a browser URL from the Azure remote %s/%s", (host, repository) => { - const url = changeRequestUrlFor("azure-devops", host, repository, 42); - expect(url).toBe("https://dev.azure.com/org/project/_git/web/pullrequest/42"); - expect(parseChangeRequestUrl(url!)).toEqual({ - host: "dev.azure.com", - repository: "org/project/_git/web", - number: 42, - }); - }); -}); diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts deleted file mode 100644 index 129e4d090ee0..000000000000 --- a/packages/shared/src/changeRequestUrl.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts"; -import { canonicalRepositoryKey } from "./sourceControl.ts"; - -/** - * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce - * from a git remote — lower case, no port, the full path below the host — because links are - * matched against those. Anything else matches nothing. - */ -export interface ChangeRequestLink { - readonly host: string; - readonly repository: string; - readonly number: number; - readonly authority?: string; -} - -function isHostOf(hostname: string, apex: string, label?: string): boolean { - if (hostname === apex || hostname.endsWith(`.${apex}`)) return true; - return label !== undefined && hostname.split(".").includes(label); -} - -export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null { - let url: URL; - try { - url = new URL(targetUrl); - } catch { - return null; - } - if (url.protocol !== "https:" && url.protocol !== "http:") return null; - const host = url.hostname.toLowerCase(); - - if (host === "git.cafe" || host === "staging.git.cafe") { - // GitCafe serves pull requests at `/pull/` and redirects the older `/pulls/` there. - const match = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match) return claim(host, match); - } else { - if (isHostOf(host, "github.com", "github")) { - const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match) return claim(host, match); - } - const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (forgejo) { - const link = claim(host, forgejo); - return link === null ? null : { ...link, authority: url.host.toLowerCase() }; - } - const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (gitlab) return claim(host, gitlab); - if (isHostOf(host, "bitbucket.org", "bitbucket")) { - const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match) return claim(host, match); - } - } - // Azure DevOps Server can use any hostname; its `_git` pull request route identifies it. - const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); - const link = claim(host, azure); - return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; -} - -function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { - const repository = match?.[1]; - const number = Number(match?.[2]); - return repository && Number.isSafeInteger(number) && number > 0 - ? { host, repository: repository.toLowerCase(), number } - : null; -} - -function remoteWebOrigin(host: string, remoteUrl: string | undefined): string { - try { - const remote = new URL(remoteUrl ?? ""); - if ( - (remote.protocol === "http:" || remote.protocol === "https:") && - (remote.hostname.toLowerCase() === host.toLowerCase() || - remote.host.toLowerCase() === host.toLowerCase()) - ) { - return remote.origin; - } - } catch { - // SSH remotes do not specify the server's web origin. - } - return `https://${host}`; -} - -export function changeRequestUrlFor( - kind: string | null | undefined, - host: string, - repository: string, - number: number, - remoteUrl?: string, -): string | null { - switch (kind) { - case "github": - return `https://${host}/${repository}/pull/${number}`; - case "gitcafe": - return `https://${host}/${repository}/pulls/${number}`; - case "forgejo": - return `${remoteWebOrigin(host, remoteUrl)}/${repository}/pulls/${number}`; - case "gitlab": - return `https://${host}/${repository}/-/merge_requests/${number}`; - case "bitbucket": - return `https://${host}/${repository}/pull-requests/${number}`; - case "azure-devops": { - const [canonicalHost = host, ...path] = canonicalRepositoryKey( - `${host}/${repository}`.toLowerCase(), - ).split("/"); - return `${remoteWebOrigin(canonicalHost, remoteUrl)}/${path.join("/")}/pullrequest/${number}`; - } - default: - return null; - } -} - -export function gitHubPullRequestBrowserUrl( - identity: RepositoryIdentity | null | undefined, - repository: string, - number: number, -): string | null { - if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null; - const repositoryPath = repository.split("/"); - if ( - repositoryPath.length !== 2 || - repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..") - ) { - return null; - } - - let origin: string | null = null; - try { - const remoteUrl = new URL(identity.locator.remoteUrl.trim()); - if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") { - origin = remoteUrl.origin; - } - } catch {} - const hostname = identity.canonicalKey.split("/")[0]; - if (origin === null && !hostname) return null; - - try { - const url = new URL(origin ?? `https://${hostname}`); - url.pathname = `/${repositoryPath.join("/")}/pull/${number}`; - return url.toString(); - } catch { - return null; - } -} - -/** - * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare - * reference through `/issues/`, including pull requests, so this only builds a candidate: the - * caller must successfully read it as a pull request before treating it as one. - */ -export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null { - let url: URL; - try { - url = new URL(targetUrl); - } catch { - return null; - } - if ( - (url.protocol !== "https:" && url.protocol !== "http:") || - !( - url.hostname.toLowerCase() === "github.com" || - url.hostname.toLowerCase().endsWith(".github.com") || - url.hostname.toLowerCase().split(".").includes("github") - ) - ) { - return null; - } - const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match?.[1] === undefined || match[2] === undefined) return null; - url.pathname = `/${match[1]}/pull/${match[2]}`; - return url.toString(); -} - -export function matchesLinkedPullRequestUrl( - linkedPullRequest: ThreadLinkedPullRequest, - targetUrl: string, -): boolean { - const linked = parseChangeRequestUrl(linkedPullRequest.url); - const target = parseChangeRequestUrl(targetUrl); - return ( - linked !== null && - target !== null && - linked.host === target.host && - linked.repository === target.repository && - linked.number === target.number && - linked.authority === target.authority - ); -} - -export function changeRequestRepositoryUrl(targetUrl: string): string | null { - const changeRequest = parseChangeRequestUrl(targetUrl); - if (changeRequest === null) return null; - const url = new URL(targetUrl); - const repositoryPath = - /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? - /^(.*?)(?:\/pulls?\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( - url.pathname, - )?.[1]; - if (!repositoryPath) return null; - url.pathname = repositoryPath; - url.search = ""; - url.hash = ""; - return url.toString(); -} - -export function siblingPullRequestUrl(url: string, number: number): string | null { - const reference = parseChangeRequestUrl(url); - if (reference === null || !Number.isSafeInteger(number) || number < 1) return null; - const sibling = new URL(url); - const route = /^\/(-\/merge_requests|pulls?|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec( - sibling.pathname.slice(reference.repository.length + 1), - )?.[1]; - if (route === undefined) return null; - sibling.pathname = `/${reference.repository}/${route}/${number}`; - sibling.search = ""; - sibling.hash = ""; - return sibling.toString(); -} diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 8073bcccd0bb..f96662e57b24 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -10,7 +10,6 @@ import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; import * as Predicate from "effect/Predicate"; import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; -import { threadPullRequestKeyOf } from "./threadPullRequests.ts"; const BACKGROUND_TURN_ITEM_TYPES = new Set([ "command_execution", @@ -303,7 +302,9 @@ function pullRequestWatchTasks( ? [] : [ { - taskId: `pull-request-watch:${threadPullRequestKeyOf(link)}`, + // Links store their normalized identity, and this id only has to be unique within the + // thread, so it reads those fields instead of parsing the URL. + taskId: `pull-request-watch:${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, description: `Watching pull request #${link.number}`, kind: "monitor" as const, }, diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 83c5a60c0cee..c86fcc1158bf 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -1,88 +1,4 @@ -import type { - OrchestrationV2AppThread, - ThreadLinkedPullRequest, - ThreadPullRequestKey, - ThreadPullRequestLink, -} from "@t3tools/contracts"; - -import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; -import { canonicalRepositoryKey } from "./sourceControl.ts"; - -type ThreadPullRequestKeySource = ThreadPullRequestKey & { - readonly authority?: string; - readonly url?: string; -}; - -export function normalizeThreadPullRequestKey( - key: ThreadPullRequestKeySource, -): ThreadPullRequestKey { - const parsed = key.url === undefined ? null : parseChangeRequestUrl(key.url); - const authority = - key.authority ?? - (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number - ? parsed.authority - : undefined); - const canonical = canonicalRepositoryKey( - `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, - ); - const separator = canonical.indexOf("/"); - return { - host: canonical.slice(0, separator), - repository: canonical.slice(separator + 1), - number: key.number, - }; -} - -export function legacyThreadPullRequestKey( - linked: Pick, - fallbackHost?: string, -): ThreadPullRequestKey { - const parsed = parseChangeRequestUrl(linked.url); - if (parsed !== null && parsed.number === linked.number) { - const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); - if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { - return normalizeThreadPullRequestKey(parsed); - } - } - let host = fallbackHost; - if (host === undefined) { - try { - host = new URL(linked.url).hostname; - } catch { - host = "unknown"; - } - } - return { - host: host.trim().toLowerCase() || "unknown", - repository: linked.repository.trim().toLowerCase(), - number: linked.number, - }; -} - -export function threadPullRequestKeysEqual( - left: ThreadPullRequestKeySource, - right: ThreadPullRequestKeySource, -): boolean { - return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right); -} - -// Clients re-derive chains for every link on each render, and normalizing parses the -// link's URL. Links are immutable and keep their identity until they change. -const normalizedKeys = new WeakMap(); - -function normalizedKeyOf(key: ThreadPullRequestKeySource): ThreadPullRequestKey { - let normalized = normalizedKeys.get(key); - if (normalized === undefined) { - normalized = normalizeThreadPullRequestKey(key); - normalizedKeys.set(key, normalized); - } - return normalized; -} - -export function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string { - const normalized = normalizedKeyOf(key); - return `${normalized.host}/${normalized.repository}#${normalized.number}`; -} +import type { ThreadLinkedPullRequest, ThreadPullRequestLink } from "@t3tools/contracts"; /** Tombstoned stack members stay in the array only so the * sync reactor does not re-add them. */ @@ -92,202 +8,12 @@ export function visibleThreadPullRequests( return links.filter((link) => link.source !== "stack-dismissed"); } -function isOpen(link: ThreadPullRequestLink): boolean { - return link.snapshot === null || link.snapshot.state === "open"; -} - function latestUpdatedAt(link: ThreadPullRequestLink): number { const value = link.snapshot?.updatedAt ?? link.linkedAt; const ms = Date.parse(value); return Number.isNaN(ms) ? 0 : ms; } -export type ThreadCurrentPullRequest = - | { readonly kind: "single"; readonly link: ThreadPullRequestLink } - | { - readonly kind: "stack"; - readonly open: ReadonlyArray; - readonly top: ThreadPullRequestLink; - }; - -function layersFromTop(chain: ThreadPullRequestChain): Array { - // `.sort()` on a fresh array, not `.toSorted()`: this runs on Hermes, which has no ES2023 - // array methods, and a TypeError here is fatal on every mobile launch that renders a stack. - return chain.layers - .map((link, index) => ({ link, index, depth: chain.depths[index]! })) - .sort((left, right) => right.depth - left.depth || right.index - left.index) - .map((entry) => entry.link); -} - -export function resolveThreadCurrentPullRequest( - links: ReadonlyArray, -): ThreadCurrentPullRequest | null { - const visible = visibleThreadPullRequests(links); - if (visible.length === 0) return null; - const open = visible.filter(isOpen); - if (open.length === 1) return { kind: "single", link: open[0]! }; - const chains = resolveThreadPullRequestChains(visible); - if (open.length > 1) { - const openChains = chains - .map((chain) => layersFromTop(chain).filter(isOpen)) - .filter((layers) => layers.length > 0) - .sort( - (left, right) => - Math.max(...right.map((link) => Date.parse(link.linkedAt))) - - Math.max(...left.map((link) => Date.parse(link.linkedAt))), - ); - const ordered = openChains.flat(); - return { kind: "stack", open: ordered, top: ordered[0]! }; - } - if (chains.length === 1) { - return { kind: "single", link: layersFromTop(chains[0]!)[0]! }; - } - const terminal = [...visible].sort( - (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left), - ); - return { kind: "single", link: terminal[0]! }; -} - -export function resolveThreadCurrentPullRequestLink( - links: ReadonlyArray, -): ThreadPullRequestLink | null { - const current = resolveThreadCurrentPullRequest(links); - if (current === null) return null; - return current.kind === "single" ? current.link : current.top; -} - -export interface ThreadPullRequestChain { - readonly kind: "native" | "derived"; - readonly layers: ReadonlyArray; - /** One per layer: 0 for the base, parent's depth + 1 above it. Siblings share a depth. */ - readonly depths: ReadonlyArray; -} - -export function resolveThreadPullRequestChains( - links: ReadonlyArray, -): ReadonlyArray { - const visible = visibleThreadPullRequests(links); - const chains: Array = []; - const placed = new Set(); - - const nativeStacks = new Map>(); - for (const link of visible) { - if (link.stack === null) continue; - const key = normalizedKeyOf(link); - const stackKey = `${key.host}/${key.repository}#stack:${link.stack.id}`; - const members = nativeStacks.get(stackKey) ?? []; - members.push(link); - nativeStacks.set(stackKey, members); - } - for (const members of nativeStacks.values()) { - // Saved snapshots can lag behind newer members of the same stack. - const memberNumbers = new Set(members.map((member) => member.number)); - let layers = members[0]!.stack!.layers; - let coverage = layers.filter((layer) => memberNumbers.has(layer.number)).length; - for (const member of members.slice(1)) { - const candidate = member.stack!.layers; - const candidateCoverage = candidate.filter((layer) => memberNumbers.has(layer.number)).length; - if ( - candidateCoverage > coverage || - (candidateCoverage === coverage && candidate.length > layers.length) - ) { - layers = candidate; - coverage = candidateCoverage; - } - } - const order = new Map(layers.map((layer, index) => [layer.number, index])); - members.sort( - (left, right) => - (order.get(left.number) ?? layers.length) - (order.get(right.number) ?? layers.length), - ); - for (const member of members) placed.add(threadPullRequestKeyOf(member)); - chains.push({ kind: "native", layers: members, depths: members.map((_, index) => index) }); - } - - const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link))); - const branchKey = (link: ThreadPullRequestLink, branch: string) => { - const key = normalizedKeyOf(link); - return `${key.host}/${key.repository}:${branch}`; - }; - const byHead = new Map(); - for (const link of remaining) { - if (link.snapshot === null) continue; - const key = branchKey(link, link.snapshot.headBranch); - byHead.set(key, byHead.has(key) ? null : link); - } - const parentOf = (link: ThreadPullRequestLink) => { - if (link.snapshot === null) return undefined; - const parent = byHead.get(branchKey(link, link.snapshot.baseBranch)); - return parent == null || parent === link ? undefined : parent; - }; - const children = new Map>(); - for (const link of remaining) { - const parent = parentOf(link); - if (parent === undefined) continue; - const key = threadPullRequestKeyOf(parent); - const siblings = children.get(key) ?? []; - siblings.push(link); - children.set(key, siblings); - } - for (const base of remaining) { - if (parentOf(base) !== undefined) continue; - const layers: Array = []; - const depths: Array = []; - const pending: Array<{ readonly link: ThreadPullRequestLink; readonly depth: number }> = [ - { link: base, depth: 0 }, - ]; - for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - const key = threadPullRequestKeyOf(next.link); - // Two links with one identity would otherwise share a child list and expand forever. - if (placed.has(key)) continue; - placed.add(key); - layers.push(next.link); - depths.push(next.depth); - const below = children.get(key) ?? []; - for (let index = below.length - 1; index >= 0; index -= 1) { - pending.push({ link: below[index]!, depth: next.depth + 1 }); - } - } - if (layers.length > 0) chains.push({ kind: "derived", layers, depths }); - } - for (const link of remaining) { - if (!placed.has(threadPullRequestKeyOf(link))) { - chains.push({ kind: "derived", layers: [link], depths: [0] }); - } - } - return chains; -} - -export type ThreadPullRequestBadge = { - readonly state: "open" | "closed" | "merged" | "draft"; -} & ( - | { - readonly kind: "stack"; - readonly layers: number; - } - | { readonly kind: "pull-request"; readonly others: number } -); - -export function resolveThreadPullRequestBadge( - pullRequests: ReadonlyArray | undefined, -): ThreadPullRequestBadge | null { - const visible = visibleThreadPullRequests(pullRequests ?? []); - if (visible.length === 0) return null; - const states = visible.map((link) => link.snapshot?.state ?? "open"); - const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft) - ? "draft" - : states.includes("open") - ? "open" - : states.every((entry) => entry === "merged") - ? "merged" - : "closed"; - const chains = resolveThreadPullRequestChains(visible); - if (visible.length > 1 && chains.length === 1) { - return { kind: "stack", layers: visible.length, state }; - } - return { kind: "pull-request", others: visible.length - 1, state }; -} - export interface ThreadSearchPullRequest { readonly number: number; readonly title: string | null; @@ -338,70 +64,3 @@ export function threadPullRequestSearchTerms(thread: { ...(pullRequest.title ? [pullRequest.title] : []), ]); } - -/** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */ -export function threadPullRequestsOf(thread: { - readonly pullRequests?: ReadonlyArray | undefined; - readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; -}): ReadonlyArray { - if (thread.pullRequests !== undefined) return thread.pullRequests; - const linked = thread.linkedPullRequest; - return linked == null - ? [] - : [ - { - ...legacyThreadPullRequestKey(linked), - url: linked.url, - source: "manual", - linkedAt: "1970-01-01T00:00:00.000Z", - snapshot: null, - stack: null, - }, - ]; -} - -export function applyThreadPullRequestLinkUpdate( - thread: OrchestrationV2AppThread, - updated: ThreadPullRequestLink, -): OrchestrationV2AppThread { - return { - ...thread, - pullRequests: threadPullRequestsOf(thread).map((link) => - threadPullRequestKeysEqual(link, updated) ? updated : link, - ), - }; -} - -export interface PullRequestListLine { - readonly link: ThreadPullRequestLink; - readonly depth: number; - readonly chainKey: string; - readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null; -} - -function chainKeyOf(chain: ThreadPullRequestChain): string { - const bottom = chain.layers[0]!; - return `${bottom.host}/${bottom.repository}#${bottom.number}`; -} - -export function pullRequestListLines( - chains: ReadonlyArray, -): ReadonlyArray { - const ordered = [...chains].sort( - (left, right) => - Math.max(...right.layers.map(latestUpdatedAt)) - - Math.max(...left.layers.map(latestUpdatedAt)), - ); - return ordered.flatMap((chain) => { - const chainKey = chainKeyOf(chain); - return chain.layers.map((link, index) => ({ - link, - depth: chain.depths[index]!, - chainKey, - stack: - index === 0 && chain.layers.length > 1 - ? { kind: chain.kind, size: chain.layers.length } - : null, - })); - }); -} diff --git a/packages/source-control-azure-devops/src/client/definition.test.ts b/packages/source-control-azure-devops/src/client/definition.test.ts new file mode 100644 index 000000000000..c4a0e4fd526b --- /dev/null +++ b/packages/source-control-azure-devops/src/client/definition.test.ts @@ -0,0 +1,104 @@ +import type { ChangeRequestProjectIdentity } from "@t3tools/source-control-core/client/definition"; +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("Azure DevOps change request URLs", () => { + it("reads both hosted URL forms", () => { + expect(parse("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17")).toEqual({ + host: "dev.azure.com", + repository: "acme/platform/_git/t3code", + number: 17, + repositoryUrl: "https://dev.azure.com/acme/platform/_git/t3code", + }); + expect( + parse("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"), + ).toMatchObject({ + host: "acme.visualstudio.com", + repository: "platform/_git/t3code", + number: 17, + }); + }); + + it("keeps Azure DevOps Server ports in the authority, on any hostname", () => { + expect( + parse("https://GIT.CAFE:8443/tfs/Collection/Project/_git/Repo/pullrequest/123?view=files"), + ).toEqual({ + host: "git.cafe", + authority: "git.cafe:8443", + repository: "tfs/collection/project/_git/repo", + number: 123, + repositoryUrl: "https://git.cafe:8443/tfs/Collection/Project/_git/Repo", + }); + expect(parse("https://scm.example.test/tfs/c/p/_git/r/pullrequest/1")).not.toHaveProperty( + "authority", + ); + }); + + it("claims nothing without the _git route or a valid number", () => { + for (const url of [ + "https://scm.example.test/tfs/Collection/Project/_git/Repo", + "https://scm.example.test/tfs/Collection/Project/pullrequest/123", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992", + ]) { + expect(parse(url), url).toBeNull(); + } + }); + + it.each([ + ["ssh.dev.azure.com", "v3/org/project/web"], + ["org.visualstudio.com", "defaultcollection/project/_git/web"], + ["dev.azure.com", "org/project/_git/web"], + ])("builds a canonical browser URL from the remote %s/%s", (host, repository) => { + expect(definition.changeRequestUrl({ host, repository, number: 42 })).toBe( + "https://dev.azure.com/org/project/_git/web/pullrequest/42", + ); + }); + + it("keeps an Azure DevOps Server's HTTP origin and builds below a web URL", () => { + expect( + definition.changeRequestUrl({ + host: "scm.example.test", + repository: "tfs/collection/project/_git/repo", + number: 124, + remoteUrl: "http://dummy:secret@scm.example.test:8080/tfs/Collection/Project/_git/Repo", + }), + ).toBe("http://scm.example.test:8080/tfs/collection/project/_git/repo/pullrequest/124"); + expect( + definition.changeRequestUrl({ + host: "dev.azure.com", + repository: "acme/project/_git/web", + number: 43, + webUrl: "https://dev.azure.com/acme/project/_git/web", + }), + ).toBe("https://dev.azure.com/acme/project/_git/web/pullrequest/43"); + }); + + it.each([ + ["ssh.dev.azure.com/v3/org-a/project/web", "git@ssh.dev.azure.com:v3/org-a/project/web"], + [ + "vs-ssh.visualstudio.com/v3/org-a/project/web", + "git@vs-ssh.visualstudio.com:v3/org-a/project/web", + ], + [ + "org-a.visualstudio.com/defaultcollection/project/_git/web", + "https://org-a.visualstudio.com/DefaultCollection/Project/_git/Web", + ], + ["dev.azure.com/org-a/project/_git/web", "https://org-a@dev.azure.com/org-a/Project/_git/Web"], + ])("matches browser references against the checkout %s", (canonicalKey, remoteUrl) => { + const identity: ChangeRequestProjectIdentity = { + canonicalKey, + displayName: canonicalKey.split("/").slice(1).join("/"), + locator: { source: "git-remote", remoteName: "origin", remoteUrl }, + }; + const link = parse("https://dev.azure.com/org-a/project/_git/web/pullrequest/42")!; + expect(definition.isChangeRequestInRepository(identity, link)).toBe(true); + for (const repository of ["org-b/project/_git/web", "org-a/other-project/_git/web"]) { + expect(definition.isChangeRequestInRepository(identity, { ...link, repository })).toBe(false); + } + }); +}); diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index 0149e9fa928a..ac83fb8fc3af 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -1,11 +1,15 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestAuthorityOfProject, - isChangeRequestPath, + parseChangeRequestPath, + remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; +// Azure DevOps Server runs on any hostname; its `_git` route identifies it. +const CHANGE_REQUEST_PATH = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u; const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; @@ -33,8 +37,19 @@ export const definition = defineSourceControlClient({ publishHost: () => "dev.azure.com", newRepositoryOwner: () => null, defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`, + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { + if (webUrl) return changeRequestUrlBelow(webUrl, `pullrequest/${number}`); + const [canonicalHost = host, ...path] = canonicalRepositoryKey( + `${host}/${repository}`.toLowerCase(), + ).split("/"); + return `${remoteWebOrigin(canonicalHost, remoteUrl)}/${path.join("/")}/pullrequest/${number}`; + }, + // Servers on different ports are different servers, so a port stays in the link. + parseChangeRequestUrl: (url) => { + const link = parseChangeRequestPath(url, CHANGE_REQUEST_PATH); + return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; + }, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "merge", "ready", @@ -53,12 +68,10 @@ export const definition = defineSourceControlClient({ return args ? checkoutCommandId(args) : null; }, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestAuthorityOfProject(identity, link) && canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()), canReadChangeRequestOnHost: () => false, - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), }); diff --git a/packages/source-control-azure-devops/src/server/driver.ts b/packages/source-control-azure-devops/src/server/driver.ts index 3932e8425437..1d218c303854 100644 --- a/packages/source-control-azure-devops/src/server/driver.ts +++ b/packages/source-control-azure-devops/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("azure-devops"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: AzureDevOpsSourceControlProvider.make, discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), diff --git a/packages/source-control-bitbucket/src/client/definition.test.ts b/packages/source-control-bitbucket/src/client/definition.test.ts new file mode 100644 index 000000000000..5c53884c3376 --- /dev/null +++ b/packages/source-control-bitbucket/src/client/definition.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("Bitbucket change request URLs", () => { + it("reads a pull request on Bitbucket hosts", () => { + expect(parse("https://bitbucket.org/workspace/repo/pull-requests/5/diff")).toEqual({ + host: "bitbucket.org", + repository: "workspace/repo", + number: 5, + repositoryUrl: "https://bitbucket.org/workspace/repo", + }); + expect(parse("https://code.bitbucket.example.test/team/repo/pull-requests/6")).toMatchObject({ + host: "code.bitbucket.example.test", + number: 6, + }); + }); + + it("leaves other hosts alone", () => { + expect(parse("https://scm.example.test/workspace/repo/pull-requests/5")).toBeNull(); + }); + + it("builds pull request URLs below the repository", () => { + expect( + definition.changeRequestUrl({ host: "bitbucket.org", repository: "w/repo", number: 5 }), + ).toBe("https://bitbucket.org/w/repo/pull-requests/5"); + expect( + definition.changeRequestUrl({ + host: "bitbucket.org", + repository: "w/repo", + number: 5, + webUrl: "https://bitbucket.org/w/repo", + }), + ).toBe("https://bitbucket.org/w/repo/pull-requests/5"); + }); +}); diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 65d11a707596..2d423fbb2279 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -6,17 +6,29 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("bitbucket"); +function isBitbucketHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ( + host === "bitbucket.org" || + host.endsWith(".bitbucket.org") || + host.split(".").includes("bitbucket") + ); +} + const text = (annotations: Schema.Annotations.Key) => TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), @@ -62,8 +74,13 @@ export const definition = defineSourceControlClient({ // A new repository belongs to a workspace, which the signed-in account does not name. newRepositoryOwner: () => null, defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/pull-requests/${number}`, + changeRequestUrl: ({ host, repository, number, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `pull-requests/${number}`) + : `https://${host}/${repository}/pull-requests/${number}`, + parseChangeRequestUrl: (url) => + isBitbucketHost(url.hostname) ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) : null, + changeRequestUrlFromReferenceAutolink: () => null, // No endpoint reopens a declined pull request, and nothing documented moves one in or out of // draft, so neither is offered rather than failing when pressed. changeRequestActions: new Set(["merge", "close"] as const), @@ -79,12 +96,10 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, isChangeRequestReference: () => false, - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(KIND, identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"), settings, }); diff --git a/packages/source-control-bitbucket/src/server/driver.ts b/packages/source-control-bitbucket/src/server/driver.ts index 381ce7bf206f..19d35d82015c 100644 --- a/packages/source-control-bitbucket/src/server/driver.ts +++ b/packages/source-control-bitbucket/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("bitbucket"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: BitbucketSourceControlProvider.make, discovery: BitbucketSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-core/package.json b/packages/source-control-core/package.json index d463190918b2..4ecd07cc8678 100644 --- a/packages/source-control-core/package.json +++ b/packages/source-control-core/package.json @@ -7,6 +7,10 @@ "types": "./src/client/definition.ts", "import": "./src/client/definition.ts" }, + "./client/threadPullRequests": { + "types": "./src/client/threadPullRequests.ts", + "import": "./src/client/threadPullRequests.ts" + }, "./server/diffFileRevisions": { "types": "./src/server/diffFileRevisions.ts", "import": "./src/server/diffFileRevisions.ts" diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index fde7ab9fba00..69a4fa402627 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -5,9 +5,25 @@ import { type RepositoryIdentity, SourceControlProviderKind, } from "@t3tools/contracts"; -import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; import * as Schema from "effect/Schema"; +/** + * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce + * from a git remote — lower case, no port, the full path below the host — because links are + * matched against those. `authority` keeps the port for hosts whose identity includes it. + */ +export interface ChangeRequestLink { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly authority?: string; +} + +export interface ParsedChangeRequestUrl extends ChangeRequestLink { + /** The repository's browser URL, without query or hash. */ + readonly repositoryUrl: string; +} + export interface ChangeRequestTerminology { readonly shortLabel: string; readonly singular: string; @@ -18,6 +34,7 @@ export interface ChangeRequestUrlInput { readonly repository: string; readonly number: number; readonly remoteUrl?: string | undefined; + /** The repository's browser URL; when given, the result is this plus the host's route. */ readonly webUrl?: string | undefined; } @@ -46,6 +63,14 @@ export interface SourceControlClientDefinition { readonly newRepositoryOwner: (account: string | null) => { readonly owner: string | null } | null; readonly defaultCloneTransport: "https" | "ssh"; readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; + /** Null when the URL is not one of this host's change requests. */ + readonly parseChangeRequestUrl: (url: URL) => ParsedChangeRequestUrl | null; + /** + * The change request URL a bare `#123` autolink might name, for hosts that write every + * reference through their issue route. Only a candidate: the caller must read it as a change + * request before treating it as one. + */ + readonly changeRequestUrlFromReferenceAutolink: (url: URL) => string | null; /** * The actions this host can take on a change request, the same list its server provider * declares in `capabilities.actions`. @@ -65,9 +90,7 @@ export interface SourceControlClientDefinition { identity: ChangeRequestProjectIdentity, link: ChangeRequestLink, ) => boolean; - readonly changeRequestUrlHost: (url: URL) => string; readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; - readonly isChangeRequestUrl: (url: string) => boolean; readonly settings?: SourceControlHostSettingsSchema; } @@ -75,14 +98,44 @@ export type SourceControlHostSettingsSchema = { readonly fields: Readonly>; } & Schema.Decoder; -export function isChangeRequestPath(url: string, route: string): boolean { +/** + * Reads a change request from `url` with `route`, whose first group is the repository path and + * second the number. + */ +export function parseChangeRequestPath(url: URL, route: RegExp): ParsedChangeRequestUrl | null { + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const match = route.exec(url.pathname); + const repository = match?.[1]; + const number = Number(match?.[2]); + return repository && Number.isSafeInteger(number) && number > 0 + ? { + host: url.hostname.toLowerCase(), + repository: repository.toLowerCase(), + number, + repositoryUrl: `${url.origin}/${repository}`, + } + : null; +} + +export function changeRequestUrlBelow(webUrl: string, route: string): string { + return `${webUrl.replace(/\/+$/u, "")}/${route}`; +} + +/** An HTTP remote on `host` names the server's web origin, port included; SSH remotes do not. */ +export function remoteWebOrigin(host: string, remoteUrl: string | undefined): string { try { - const path = new URL(url).pathname; - const at = path.indexOf(route); - return at > 0 && /^\d+(?:\/|$)/u.test(path.slice(at + route.length)); + const remote = new URL(remoteUrl ?? ""); + if ( + (remote.protocol === "http:" || remote.protocol === "https:") && + (remote.hostname.toLowerCase() === host.toLowerCase() || + remote.host.toLowerCase() === host.toLowerCase()) + ) { + return remote.origin; + } } catch { - return false; + // SSH remotes do not specify the server's web origin. } + return `https://${host}`; } export function defineSourceControlClient( @@ -150,6 +203,8 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { newRepositoryOwner: () => null, defaultCloneTransport: "ssh", changeRequestUrl: () => null, + parseChangeRequestUrl: () => null, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set(), checkoutCommand: () => null, authorProfileUrl: () => null, @@ -157,26 +212,49 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, isChangeRequestReference: () => false, - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(SourceControlProviderKind.make("unknown"), identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(SourceControlProviderKind.make("unknown"), identity, link), - isChangeRequestUrl: () => false, }; +export interface ParsedChangeRequest { + readonly definition: SourceControlClientDefinition; + readonly link: ParsedChangeRequestUrl; +} + export interface SourceControlClientRegistry { readonly definitions: ReadonlyArray; readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; readonly find: (kind: string) => SourceControlClientDefinition | undefined; readonly findByPublicHost: (hostname: string) => SourceControlClientDefinition | undefined; + /** The first host, in registry order, that reads `url` as one of its change requests. */ + readonly parseChangeRequestUrl: (url: string) => ParsedChangeRequest | null; readonly findByChangeRequestUrl: (url: string) => SourceControlClientDefinition | undefined; readonly hostLabelForChangeRequestUrl: (url: string) => string; + readonly isSameChangeRequest: (left: string, right: string) => boolean; + readonly changeRequestUrlFromReferenceAutolink: (url: string) => string | null; +} + +function httpUrl(value: string): URL | null { + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url : null; + } catch { + return null; + } } +/** + * `definitions` is display order, and its first entry is what `get` returns for a repository + * that has not reported its host yet. A change request URL goes to the first definition that + * reads it, trying `claimChangeRequestUrlsFirst` before the rest, so a host that claims a route + * only on its own hostnames can precede one that claims the route everywhere. + */ export function makeSourceControlClientRegistry( definitions: ReadonlyArray, + options?: { readonly claimChangeRequestUrlsFirst?: ReadonlyArray }, ): SourceControlClientRegistry { const byKind = new Map(); for (const definition of definitions) { @@ -185,6 +263,20 @@ export function makeSourceControlClientRegistry( } byKind.set(definition.kind, definition); } + const first = new Set(options?.claimChangeRequestUrlsFirst); + const claimOrder = [ + ...definitions.filter((definition) => first.has(definition.kind)), + ...definitions.filter((definition) => !first.has(definition.kind)), + ]; + const parseChangeRequestUrl = (value: string): ParsedChangeRequest | null => { + const url = httpUrl(value); + if (url === null) return null; + for (const definition of claimOrder) { + const link = definition.parseChangeRequestUrl(url); + if (link !== null) return { definition, link }; + } + return null; + }; return { definitions, get: (kind) => @@ -194,10 +286,31 @@ export function makeSourceControlClientRegistry( const host = hostname.toLowerCase(); return definitions.find((definition) => definition.publicHost === host); }, - findByChangeRequestUrl: (url) => - definitions.find((definition) => definition.isChangeRequestUrl(url)), + parseChangeRequestUrl, + findByChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition, hostLabelForChangeRequestUrl: (url) => - definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host", + parseChangeRequestUrl(url)?.definition.label ?? "the host", + isSameChangeRequest: (left, right) => { + const a = parseChangeRequestUrl(left)?.link; + const b = parseChangeRequestUrl(right)?.link; + return ( + a !== undefined && + b !== undefined && + a.host === b.host && + a.repository === b.repository && + a.number === b.number && + a.authority === b.authority + ); + }, + changeRequestUrlFromReferenceAutolink: (value) => { + const url = httpUrl(value); + if (url === null) return null; + for (const definition of claimOrder) { + const candidate = definition.changeRequestUrlFromReferenceAutolink(url); + if (candidate !== null) return candidate; + } + return null; + }, }; } diff --git a/packages/source-control-core/src/client/threadPullRequests.ts b/packages/source-control-core/src/client/threadPullRequests.ts new file mode 100644 index 000000000000..a16fbae8572f --- /dev/null +++ b/packages/source-control-core/src/client/threadPullRequests.ts @@ -0,0 +1,372 @@ +import type { + OrchestrationV2AppThread, + ThreadLinkedPullRequest, + ThreadPullRequestKey, + ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; + +import type { SourceControlClientRegistry } from "./definition.ts"; + +type ThreadPullRequestKeySource = ThreadPullRequestKey & { + readonly authority?: string; + readonly url?: string; +}; + +function isOpen(link: ThreadPullRequestLink): boolean { + return link.snapshot === null || link.snapshot.state === "open"; +} + +function latestUpdatedAt(link: ThreadPullRequestLink): number { + const value = link.snapshot?.updatedAt ?? link.linkedAt; + const ms = Date.parse(value); + return Number.isNaN(ms) ? 0 : ms; +} + +export type ThreadCurrentPullRequest = + | { readonly kind: "single"; readonly link: ThreadPullRequestLink } + | { + readonly kind: "stack"; + readonly open: ReadonlyArray; + readonly top: ThreadPullRequestLink; + }; + +function layersFromTop(chain: ThreadPullRequestChain): Array { + // `.sort()` on a fresh array, not `.toSorted()`: this runs on Hermes, which has no ES2023 + // array methods, and a TypeError here is fatal on every mobile launch that renders a stack. + return chain.layers + .map((link, index) => ({ link, index, depth: chain.depths[index]! })) + .sort((left, right) => right.depth - left.depth || right.index - left.index) + .map((entry) => entry.link); +} + +export interface ThreadPullRequestChain { + readonly kind: "native" | "derived"; + readonly layers: ReadonlyArray; + /** One per layer: 0 for the base, parent's depth + 1 above it. Siblings share a depth. */ + readonly depths: ReadonlyArray; +} + +export type ThreadPullRequestBadge = { + readonly state: "open" | "closed" | "merged" | "draft"; +} & ( + | { + readonly kind: "stack"; + readonly layers: number; + } + | { readonly kind: "pull-request"; readonly others: number } +); + +/** + * Thread pull request identity reads a link's URL through the hosts in `registry`, so each side + * binds these once to its own registry. + */ +export function makeThreadPullRequests( + registry: Pick, +) { + const parse = (url: string) => registry.parseChangeRequestUrl(url)?.link ?? null; + + function normalizeThreadPullRequestKey(key: ThreadPullRequestKeySource): ThreadPullRequestKey { + const parsed = key.url === undefined ? null : parse(key.url); + const authority = + key.authority ?? + (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number + ? parsed.authority + : undefined); + const canonical = canonicalRepositoryKey( + `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, + ); + const separator = canonical.indexOf("/"); + return { + host: canonical.slice(0, separator), + repository: canonical.slice(separator + 1), + number: key.number, + }; + } + + function legacyThreadPullRequestKey( + linked: Pick, + fallbackHost?: string, + ): ThreadPullRequestKey { + const parsed = parse(linked.url); + if (parsed !== null && parsed.number === linked.number) { + const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); + if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { + return normalizeThreadPullRequestKey(parsed); + } + } + let host = fallbackHost; + if (host === undefined) { + try { + host = new URL(linked.url).hostname; + } catch { + host = "unknown"; + } + } + return { + host: host.trim().toLowerCase() || "unknown", + repository: linked.repository.trim().toLowerCase(), + number: linked.number, + }; + } + + function threadPullRequestKeysEqual( + left: ThreadPullRequestKeySource, + right: ThreadPullRequestKeySource, + ): boolean { + return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right); + } + + // Clients re-derive chains for every link on each render, and normalizing parses the + // link's URL. Links are immutable and keep their identity until they change. + const normalizedKeys = new WeakMap(); + + function normalizedKeyOf(key: ThreadPullRequestKeySource): ThreadPullRequestKey { + let normalized = normalizedKeys.get(key); + if (normalized === undefined) { + normalized = normalizeThreadPullRequestKey(key); + normalizedKeys.set(key, normalized); + } + return normalized; + } + + function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string { + const normalized = normalizedKeyOf(key); + return `${normalized.host}/${normalized.repository}#${normalized.number}`; + } + + function resolveThreadCurrentPullRequest( + links: ReadonlyArray, + ): ThreadCurrentPullRequest | null { + const visible = visibleThreadPullRequests(links); + if (visible.length === 0) return null; + const open = visible.filter(isOpen); + if (open.length === 1) return { kind: "single", link: open[0]! }; + const chains = resolveThreadPullRequestChains(visible); + if (open.length > 1) { + const openChains = chains + .map((chain) => layersFromTop(chain).filter(isOpen)) + .filter((layers) => layers.length > 0) + .sort( + (left, right) => + Math.max(...right.map((link) => Date.parse(link.linkedAt))) - + Math.max(...left.map((link) => Date.parse(link.linkedAt))), + ); + const ordered = openChains.flat(); + return { kind: "stack", open: ordered, top: ordered[0]! }; + } + if (chains.length === 1) { + return { kind: "single", link: layersFromTop(chains[0]!)[0]! }; + } + const terminal = [...visible].sort( + (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left), + ); + return { kind: "single", link: terminal[0]! }; + } + + function resolveThreadCurrentPullRequestLink( + links: ReadonlyArray, + ): ThreadPullRequestLink | null { + const current = resolveThreadCurrentPullRequest(links); + if (current === null) return null; + return current.kind === "single" ? current.link : current.top; + } + + function resolveThreadPullRequestChains( + links: ReadonlyArray, + ): ReadonlyArray { + const visible = visibleThreadPullRequests(links); + const chains: Array = []; + const placed = new Set(); + + const nativeStacks = new Map>(); + for (const link of visible) { + if (link.stack === null) continue; + const key = normalizedKeyOf(link); + const stackKey = `${key.host}/${key.repository}#stack:${link.stack.id}`; + const members = nativeStacks.get(stackKey) ?? []; + members.push(link); + nativeStacks.set(stackKey, members); + } + for (const members of nativeStacks.values()) { + // Saved snapshots can lag behind newer members of the same stack. + const memberNumbers = new Set(members.map((member) => member.number)); + let layers = members[0]!.stack!.layers; + let coverage = layers.filter((layer) => memberNumbers.has(layer.number)).length; + for (const member of members.slice(1)) { + const candidate = member.stack!.layers; + const candidateCoverage = candidate.filter((layer) => + memberNumbers.has(layer.number), + ).length; + if ( + candidateCoverage > coverage || + (candidateCoverage === coverage && candidate.length > layers.length) + ) { + layers = candidate; + coverage = candidateCoverage; + } + } + const order = new Map(layers.map((layer, index) => [layer.number, index])); + members.sort( + (left, right) => + (order.get(left.number) ?? layers.length) - (order.get(right.number) ?? layers.length), + ); + for (const member of members) placed.add(threadPullRequestKeyOf(member)); + chains.push({ kind: "native", layers: members, depths: members.map((_, index) => index) }); + } + + const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link))); + const branchKey = (link: ThreadPullRequestLink, branch: string) => { + const key = normalizedKeyOf(link); + return `${key.host}/${key.repository}:${branch}`; + }; + const byHead = new Map(); + for (const link of remaining) { + if (link.snapshot === null) continue; + const key = branchKey(link, link.snapshot.headBranch); + byHead.set(key, byHead.has(key) ? null : link); + } + const parentOf = (link: ThreadPullRequestLink) => { + if (link.snapshot === null) return undefined; + const parent = byHead.get(branchKey(link, link.snapshot.baseBranch)); + return parent == null || parent === link ? undefined : parent; + }; + const children = new Map>(); + for (const link of remaining) { + const parent = parentOf(link); + if (parent === undefined) continue; + const key = threadPullRequestKeyOf(parent); + const siblings = children.get(key) ?? []; + siblings.push(link); + children.set(key, siblings); + } + for (const base of remaining) { + if (parentOf(base) !== undefined) continue; + const layers: Array = []; + const depths: Array = []; + const pending: Array<{ readonly link: ThreadPullRequestLink; readonly depth: number }> = [ + { link: base, depth: 0 }, + ]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + const key = threadPullRequestKeyOf(next.link); + // Two links with one identity would otherwise share a child list and expand forever. + if (placed.has(key)) continue; + placed.add(key); + layers.push(next.link); + depths.push(next.depth); + const below = children.get(key) ?? []; + for (let index = below.length - 1; index >= 0; index -= 1) { + pending.push({ link: below[index]!, depth: next.depth + 1 }); + } + } + if (layers.length > 0) chains.push({ kind: "derived", layers, depths }); + } + for (const link of remaining) { + if (!placed.has(threadPullRequestKeyOf(link))) { + chains.push({ kind: "derived", layers: [link], depths: [0] }); + } + } + return chains; + } + + function resolveThreadPullRequestBadge( + pullRequests: ReadonlyArray | undefined, + ): ThreadPullRequestBadge | null { + const visible = visibleThreadPullRequests(pullRequests ?? []); + if (visible.length === 0) return null; + const states = visible.map((link) => link.snapshot?.state ?? "open"); + const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft) + ? "draft" + : states.includes("open") + ? "open" + : states.every((entry) => entry === "merged") + ? "merged" + : "closed"; + const chains = resolveThreadPullRequestChains(visible); + if (visible.length > 1 && chains.length === 1) { + return { kind: "stack", layers: visible.length, state }; + } + return { kind: "pull-request", others: visible.length - 1, state }; + } + + /** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */ + function threadPullRequestsOf(thread: { + readonly pullRequests?: ReadonlyArray | undefined; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + }): ReadonlyArray { + if (thread.pullRequests !== undefined) return thread.pullRequests; + const linked = thread.linkedPullRequest; + return linked == null + ? [] + : [ + { + ...legacyThreadPullRequestKey(linked), + url: linked.url, + source: "manual", + linkedAt: "1970-01-01T00:00:00.000Z", + snapshot: null, + stack: null, + }, + ]; + } + + function applyThreadPullRequestLinkUpdate( + thread: OrchestrationV2AppThread, + updated: ThreadPullRequestLink, + ): OrchestrationV2AppThread { + return { + ...thread, + pullRequests: threadPullRequestsOf(thread).map((link) => + threadPullRequestKeysEqual(link, updated) ? updated : link, + ), + }; + } + + return { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadCurrentPullRequest, + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestChains, + resolveThreadPullRequestBadge, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, + }; +} + +export interface PullRequestListLine { + readonly link: ThreadPullRequestLink; + readonly depth: number; + readonly chainKey: string; + readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null; +} + +function chainKeyOf(chain: ThreadPullRequestChain): string { + const bottom = chain.layers[0]!; + return `${bottom.host}/${bottom.repository}#${bottom.number}`; +} + +export function pullRequestListLines( + chains: ReadonlyArray, +): ReadonlyArray { + const ordered = [...chains].sort( + (left, right) => + Math.max(...right.layers.map(latestUpdatedAt)) - + Math.max(...left.layers.map(latestUpdatedAt)), + ); + return ordered.flatMap((chain) => { + const chainKey = chainKeyOf(chain); + return chain.layers.map((link, index) => ({ + link, + depth: chain.depths[index]!, + chainKey, + stack: + index === 0 && chain.layers.length > 1 + ? { kind: chain.kind, size: chain.layers.length } + : null, + })); + }); +} diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index aef99bf5e4f9..360868770abf 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -1,6 +1,8 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; +import type { SourceControlClientDefinition } from "../client/definition.ts"; + import type { SourceControlProviderDiscoverySpec } from "./discovery.ts"; import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -14,6 +16,7 @@ export interface SourceControlDriverInstance { export interface SourceControlDriver { readonly kind: SourceControlProviderKind; + readonly definition: SourceControlClientDefinition; readonly make: Effect.Effect; } diff --git a/packages/source-control-forgejo/src/client/definition.test.ts b/packages/source-control-forgejo/src/client/definition.test.ts new file mode 100644 index 000000000000..74d4cddbcd4d --- /dev/null +++ b/packages/source-control-forgejo/src/client/definition.test.ts @@ -0,0 +1,104 @@ +import type { ChangeRequestProjectIdentity } from "@t3tools/source-control-core/client/definition"; +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url))!; + +const identity = ( + remoteUrl: string, + fields: Partial = {}, +): ChangeRequestProjectIdentity => ({ + canonicalKey: "forge.example/git/team/repo", + displayName: "git/team/repo", + locator: { source: "git-remote", remoteName: "origin", remoteUrl }, + ...fields, +}); + +const onHost = (project: ChangeRequestProjectIdentity, link: ReturnType) => + definition.isChangeRequestInRepository(project, link) || + definition.canReadChangeRequestOnHost(project, link); + +describe("Forgejo change request URLs", () => { + it("reads nested paths and keeps the server's port as the authority", () => { + expect(parse("http://git.example.test:3000/Team/Repo/pulls/42/files")).toEqual({ + host: "git.example.test", + authority: "git.example.test:3000", + repository: "team/repo", + number: 42, + repositoryUrl: "http://git.example.test:3000/Team/Repo", + }); + expect(parse("https://github.internal/team/repo/pulls/7")).toMatchObject({ + host: "github.internal", + authority: "github.internal", + }); + }); + + it("claims only its own route", () => { + for (const url of [ + "https://forge.example/team/repo/pull/7", + "https://forge.example/repo/pulls/7", + "https://forge.example/team/repo/pulls/abc", + ]) { + expect(definition.parseChangeRequestUrl(new URL(url)), url).toBeNull(); + } + }); + + it.each([ + ["http://forge.example:3000/git/owner/repo.git", "http://forge.example:3000"], + ["git@forge.example:git/owner/repo.git", "https://forge.example"], + ["http://other.example:3000/git/owner/repo.git", "https://forge.example"], + ])("keeps the matching web origin from %s", (remoteUrl, origin) => { + expect( + definition.changeRequestUrl({ + host: "forge.example", + repository: "git/owner/repo", + number: 42, + remoteUrl, + }), + ).toBe(`${origin}/git/owner/repo/pulls/42`); + }); + + it("keeps the origin when the host already carries its port", () => { + expect( + definition.changeRequestUrl({ + host: "forge.example:3000", + repository: "team/repo", + number: 42, + remoteUrl: "http://forge.example:3000/team/repo.git", + }), + ).toBe("http://forge.example:3000/team/repo/pulls/42"); + }); + + it("matches projects by HTTP port at repository and host level", () => { + const [atThree, atFour] = [3000, 4000].map((port) => + identity(`http://forge.example:${port}/git/team/repo.git`), + ); + const link = parse("http://forge.example:4000/git/team/repo/pulls/42"); + expect(definition.isChangeRequestInRepository(atFour!, link)).toBe(true); + expect(onHost(atFour!, { ...link, repository: "git/team/other" })).toBe(true); + expect(definition.isChangeRequestInRepository(atThree!, link)).toBe(false); + expect(onHost(atThree!, link)).toBe(false); + }); + + it("lets tea resolve the web port for SSH remotes, and honours a resolved web URL", () => { + const link = parse("http://forge.example:4000/git/team/repo/pulls/42"); + expect( + definition.isChangeRequestInRepository(identity("git@forge.example:git/team/repo.git"), link), + ).toBe(true); + const aliased = identity("git@ssh.forge.example:team/repo.git", { + canonicalKey: "ssh.forge.example/team/repo", + displayName: "team/repo", + webUrl: "http://forge.example:4000/git/team/repo", + }); + expect(definition.isChangeRequestInRepository(aliased, link)).toBe(true); + expect(onHost(aliased, { ...link, repository: "git/team/other" })).toBe(true); + for (const url of [ + "http://other.example:4000/git/team/repo/pulls/42", + "http://forge.example:3000/git/team/repo/pulls/42", + "http://forge.example:4000/other/team/repo/pulls/42", + ]) { + expect(onHost(aliased, parse(url)), url).toBe(false); + } + }); +}); diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index f9aa6df2a1d2..9f8c2aa3573e 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -1,17 +1,22 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + changeRequestUrlBelow, type ChangeRequestProjectIdentity, defineSourceControlClient, isChangeRequestAuthorityOfProject, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, + remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; const CHECKOUT_COMMAND = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; +// Forgejo and Gitea run on any hostname, possibly mounted below a path. +const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("forgejo"); /** @@ -43,20 +48,16 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "https", // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the // origin, which may carry a port. SSH remotes say nothing about the web origin. - changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { - if (webUrl) return `${webUrl.replace(/\/+$/, "")}/pulls/${number}`; - try { - const remote = new URL(remoteUrl ?? ""); - if ( - (remote.protocol === "http:" || remote.protocol === "https:") && - (remote.hostname.toLowerCase() === host.toLowerCase() || - remote.host.toLowerCase() === host.toLowerCase()) - ) { - return `${remote.origin}/${repository}/pulls/${number}`; - } - } catch {} - return `https://${host}/${repository}/pulls/${number}`; + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `pulls/${number}`) + : `${remoteWebOrigin(host, remoteUrl)}/${repository}/pulls/${number}`, + // A server's identity includes its port, so the link carries it. + parseChangeRequestUrl: (url) => { + const link = parseChangeRequestPath(url, CHANGE_REQUEST_PATH); + return link === null ? null : { ...link, authority: url.host.toLowerCase() }; }, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const), // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. checkoutCommand: ({ number, repositoryUrl }) => @@ -69,7 +70,6 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: (verdict) => verdict === "request-changes", checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.host, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => { if (!isChangeRequestAuthorityOfProject(identity, link)) return false; @@ -94,5 +94,4 @@ export const definition = defineSourceControlClient({ (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`)) ); }, - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"), }); diff --git a/packages/source-control-forgejo/src/server/driver.ts b/packages/source-control-forgejo/src/server/driver.ts index dc5fa6a69116..7324741656c1 100644 --- a/packages/source-control-forgejo/src/server/driver.ts +++ b/packages/source-control-forgejo/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("forgejo"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: ForgejoSourceControlProvider.make, discovery: ForgejoSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-gitcafe/src/client/definition.test.ts b/packages/source-control-gitcafe/src/client/definition.test.ts new file mode 100644 index 000000000000..4bc0c017aaa1 --- /dev/null +++ b/packages/source-control-gitcafe/src/client/definition.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("GitCafe change request URLs", () => { + it("reads its canonical /pull/ path and the older /pulls/ path on its own hosts", () => { + for (const path of ["pull", "pulls"]) { + expect(parse(`https://git.cafe/PingDotGG/T3Code/${path}/23897`), path).toEqual({ + host: "git.cafe", + repository: "pingdotgg/t3code", + number: 23897, + repositoryUrl: "https://git.cafe/PingDotGG/T3Code", + }); + } + expect(parse("https://staging.git.cafe/team/repo/pull/7/files")).toMatchObject({ + host: "staging.git.cafe", + repository: "team/repo", + number: 7, + }); + }); + + it("leaves other hosts and routes alone", () => { + for (const url of [ + "https://github.com/team/repo/pull/7", + "https://forge.example/team/repo/pulls/7", + "https://git.cafe/pingdotgg/t3code/issues/23897", + "https://git.cafe/pingdotgg/t3code/pull/abc", + "https://git.cafe/pingdotgg/t3code", + ]) { + expect(parse(url), url).toBeNull(); + } + }); + + it("builds pull request URLs below the repository", () => { + expect( + definition.changeRequestUrl({ host: "git.cafe", repository: "team/repo", number: 8 }), + ).toBe("https://git.cafe/team/repo/pulls/8"); + expect( + definition.changeRequestUrl({ + host: "git.cafe", + repository: "team/repo", + number: 8, + webUrl: "https://git.cafe/Team/Repo/", + }), + ).toBe("https://git.cafe/Team/Repo/pulls/8"); + }); +}); diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index c33cb45d5174..ac7736d541b7 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -7,15 +7,19 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; /** GitCafe has no self-hosted installs: production and staging are its only hosts. */ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); +/** GitCafe serves pull requests at `/pull/` and redirects the older `/pulls/` there. */ +const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("gitcafe"); export const settings = makeProviderSettingsSchema({ @@ -44,8 +48,17 @@ export const definition = defineSourceControlClient({ publishHost: () => "git.cafe", newRepositoryOwner: (account) => (account ? { owner: account } : null), defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/pulls/${number}`, + changeRequestUrl: ({ host, repository, number, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `pulls/${number}`) + : `https://${host}/${repository}/pulls/${number}`, + // `/pull/` and `/pulls/` are GitHub's and Forgejo's shapes too, so only GitCafe's own hosts + // claim them. + parseChangeRequestUrl: (url) => + GITCAFE_HOSTS.has(url.hostname.toLowerCase()) + ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) + : null, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "ready", "draft", @@ -58,24 +71,12 @@ export const definition = defineSourceControlClient({ authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, - // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it. checkoutCommandArgument: () => null, isChangeRequestReference: () => false, - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(KIND, identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), - isChangeRequestUrl: (url) => { - try { - return ( - GITCAFE_HOSTS.has(new URL(url).hostname.toLowerCase()) && - (isChangeRequestPath(url, "/pull/") || isChangeRequestPath(url, "/pulls/")) - ); - } catch { - return false; - } - }, settings, }); diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts index 6fce65b345a4..dca35f45b75a 100644 --- a/packages/source-control-gitcafe/src/server/driver.ts +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("gitcafe"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: GitCafeSourceControlProvider.make, discovery: GitCafeSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-github/src/client/definition.test.ts b/packages/source-control-github/src/client/definition.test.ts new file mode 100644 index 000000000000..5e2153bbb056 --- /dev/null +++ b/packages/source-control-github/src/client/definition.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("GitHub change request URLs", () => { + it("reads a pull request on any host, lower-casing host and repository", () => { + expect(parse("https://github.com/T3Tools/T3Code/pull/123/files?w=1#diff")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + repositoryUrl: "https://github.com/T3Tools/T3Code", + }); + expect(parse("https://GHE.Example.com:8443/acme/web/pull/42/")).toEqual({ + host: "ghe.example.com", + repository: "acme/web", + number: 42, + repositoryUrl: "https://ghe.example.com:8443/acme/web", + }); + }); + + it("claims only the two-segment pull route", () => { + for (const url of [ + "https://github.com/t3tools/t3code/issues/123", + "https://github.com/t3tools/t3code/pulls/123", + "https://github.com/t3tools/t3code/pull/abc", + "https://github.com/t3tools/t3code/pull/0", + "https://github.com/group/sub/repo/pull/1", + "ftp://github.com/t3tools/t3code/pull/1", + ]) { + expect(parse(url), url).toBeNull(); + } + }); + + it("builds pull request URLs below the repository, keeping an HTTP remote's origin", () => { + expect( + definition.changeRequestUrl({ + host: "github.acme.test", + repository: "platform/api", + number: 7, + remoteUrl: "http://token@github.acme.test:8443/team/default.git", + }), + ).toBe("http://github.acme.test:8443/platform/api/pull/7"); + for (const remoteUrl of [ + "git@github.acme.test:team/default.git", + "git://github.acme.test/team/default.git", + "http://other.test/team/default.git", + ]) { + expect( + definition.changeRequestUrl({ + host: "github.acme.test", + repository: "platform/api", + number: 9, + remoteUrl, + }), + remoteUrl, + ).toBe("https://github.acme.test/platform/api/pull/9"); + } + expect( + definition.changeRequestUrl({ + host: "github.com", + repository: "pull/1", + number: 43, + webUrl: "https://github.com/pull/1", + }), + ).toBe("https://github.com/pull/1/pull/43"); + }); + + // regression: #8507 the browser fallback must not open a bogus URL + it("builds nothing from an invalid repository, number, or host", () => { + for (const [host, repository, number] of [ + ["github.com", "acme", 1], + ["github.com", "../repository", 1], + ["github.com", "acme/%2e%2e", 1], + ["github.com", "acme/repository?tab=1", 1], + ["github.com", "acme/repository", 0], + ["bad host", "acme/repository", 1], + ] as const) { + expect(definition.changeRequestUrl({ host, repository, number })).toBeNull(); + } + }); + + it("turns GitHub's shared issue route into a pull request candidate", () => { + const candidate = (url: string) => + definition.changeRequestUrlFromReferenceAutolink(new URL(url)); + expect(candidate("https://github.com/pingdotgg/t3code/issues/8600#issuecomment-1")).toBe( + "https://github.com/pingdotgg/t3code/pull/8600#issuecomment-1", + ); + expect(candidate("https://code.github.example.com/acme/web/issues/42")).toBe( + "https://code.github.example.com/acme/web/pull/42", + ); + expect(candidate("https://github.com/pingdotgg/t3code/issues/not-a-number")).toBeNull(); + expect(candidate("https://code.example.test/acme/web/issues/42")).toBeNull(); + }); +}); diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 66c1cd003968..f9db4b87a3a0 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -10,18 +10,42 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, + remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; +// GitHub Enterprise Server runs on any hostname, so the `//pull/` route alone +// identifies a GitHub pull request. +const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u; +const ISSUE_PATH = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("github"); +function isGitHubHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ( + host === "github.com" || host.endsWith(".github.com") || host.split(".").includes("github") + ); +} + +function isRepositoryPath(repository: string): boolean { + const segments = repository.split("/"); + return ( + segments.length === 2 && + // GitHub owner and repository names are only these characters, so nothing here can + // be an encoded dot segment, query or fragment that `new URL` would reinterpret. + segments.every((segment) => /^[\w.-]+$/u.test(segment) && segment !== "." && segment !== "..") + ); +} + export const GitHubHostChoice = Schema.Struct({ account: Schema.optionalKey(TrimmedNonEmptyString), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), @@ -53,8 +77,25 @@ export const definition = defineSourceControlClient({ publishHost: () => "github.com", newRepositoryOwner: (account) => ({ owner: account }), defaultCloneTransport: "https", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/pull/${number}`, + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { + if (!Number.isSafeInteger(number) || number < 1) return null; + if (webUrl) return changeRequestUrlBelow(webUrl, `pull/${number}`); + if (!isRepositoryPath(repository)) return null; + try { + return new URL(`/${repository}/pull/${number}`, remoteWebOrigin(host, remoteUrl)).toString(); + } catch { + return null; + } + }, + parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + // GitHub writes every bare reference through `/issues/`, including pull requests. + changeRequestUrlFromReferenceAutolink: (url) => { + const match = isGitHubHost(url.hostname) ? ISSUE_PATH.exec(url.pathname) : null; + if (match?.[1] === undefined || match[2] === undefined) return null; + const candidate = new URL(url); + candidate.pathname = `/${match[1]}/pull/${match[2]}`; + return candidate.toString(); + }, changeRequestActions: new Set([ "merge", "ready", @@ -77,12 +118,10 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: (identity) => pullRequestHostOf(identity, KIND), isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(KIND, identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), settings, }); diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index 8aec1f6c40ac..d5247732936f 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("github"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: GitHubSourceControlProvider.make, discovery: GitHubSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-gitlab/src/client/definition.test.ts b/packages/source-control-gitlab/src/client/definition.test.ts new file mode 100644 index 000000000000..4184084e3864 --- /dev/null +++ b/packages/source-control-gitlab/src/client/definition.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("GitLab change request URLs", () => { + it("reads a merge request on any host, nested groups and all", () => { + expect(parse("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42")).toEqual({ + host: "gitlab.com", + repository: "t3tools/platform/t3code", + number: 42, + repositoryUrl: "https://gitlab.com/t3tools/platform/t3code", + }); + expect(parse("https://code.acme.test/team/project/-/merge_requests/9")).toMatchObject({ + host: "code.acme.test", + repository: "team/project", + number: 9, + }); + }); + + it("keeps pull-like segments inside nested repository paths", () => { + expect( + parse("https://gitlab.example.test/group/pull/123/repo/-/merge_requests/42")?.repositoryUrl, + ).toBe("https://gitlab.example.test/group/pull/123/repo"); + }); + + it("leaves issues and single-segment paths alone", () => { + expect(parse("https://gitlab.com/t3tools/t3code/-/issues/12")).toBeNull(); + expect(parse("https://gitlab.com/t3code/-/merge_requests/12")).toBeNull(); + }); + + it("builds merge request URLs below the repository", () => { + expect( + definition.changeRequestUrl({ + host: "gitlab.com", + repository: "group/sub/project", + number: 4, + }), + ).toBe("https://gitlab.com/group/sub/project/-/merge_requests/4"); + expect( + definition.changeRequestUrl({ + host: "gitlab.com", + repository: "group/sub/project", + number: 4, + webUrl: "https://gitlab.com/group/sub/project", + }), + ).toBe("https://gitlab.com/group/sub/project/-/merge_requests/4"); + }); +}); diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index 67cbc507cffc..a3f4a153a1a0 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -1,15 +1,19 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; +// Self-managed GitLab runs on any hostname and nests projects in groups. +const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("gitlab"); export const definition = defineSourceControlClient({ @@ -24,8 +28,12 @@ export const definition = defineSourceControlClient({ publishHost: () => "gitlab.com", newRepositoryOwner: (account) => ({ owner: account }), defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/-/merge_requests/${number}`, + changeRequestUrl: ({ host, repository, number, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `-/merge_requests/${number}`) + : `https://${host}/${repository}/-/merge_requests/${number}`, + parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "merge", "ready", @@ -42,11 +50,9 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(KIND, identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"), }); diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index 23728f63127c..93defc897d79 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -1,5 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import { changeRequestUrlFor } from "@t3tools/shared/changeRequestUrl"; import { detectSourceControlProviderFromGitRemoteUrl, normalizeGitRemoteUrl, @@ -16,6 +15,7 @@ import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSour import * as GitLabCli from "./GitLabCli.ts"; import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; +import { definition } from "../client/definition.ts"; function makeProvider(gitlab: Partial) { return GitLabSourceControlProvider.make.pipe( @@ -166,15 +166,17 @@ it.effect.each([ ), }) ?? Effect.succeed(identity) ); - const [host, ...repository] = refined.canonicalKey.split("/"); - assert.strictEqual( - changeRequestUrlFor(refined.provider, host!, repository.join("/"), 42), - expectedProvider === "gitlab" - ? "https://code.example.test/team/repo/-/merge_requests/42" - : expectedProvider === "github" - ? "https://github.com/team/repo/pull/42" - : null, - ); + if (expectedProvider === "gitlab") { + const [host, ...repository] = refined.canonicalKey.split("/"); + assert.strictEqual( + definition.changeRequestUrl({ + host: host!, + repository: repository.join("/"), + number: 42, + }), + "https://code.example.test/team/repo/-/merge_requests/42", + ); + } assert.deepStrictEqual(refined, { ...identity, provider: expectedProvider }); if (expectedProvider === "github") assert.strictEqual(authCalls, 0); }), diff --git a/packages/source-control-gitlab/src/server/driver.ts b/packages/source-control-gitlab/src/server/driver.ts index 2b555786015a..175efc16269e 100644 --- a/packages/source-control-gitlab/src/server/driver.ts +++ b/packages/source-control-gitlab/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("gitlab"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: GitLabSourceControlProvider.make, discovery: Effect.succeed(GitLabSourceControlProvider.discovery), From 3d4c75370cd7d6ec91bb5c9763744125adc316ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:33 -0700 Subject: [PATCH 03/65] refactor(source-control): forges recognise their own remotes and repository keys (#23993) Co-authored-by: Claude Opus 5.5 --- .../pullRequestNavigation.test.ts | 4 +- apps/server/src/git/GitManager.ts | 23 +- .../src/git/linkCreatedPullRequest.test.ts | 3 +- apps/server/src/git/linkCreatedPullRequest.ts | 9 +- .../toolkits/pullRequests/handlers.test.ts | 11 +- .../src/mcp/toolkits/pullRequests/handlers.ts | 8 +- .../ThreadPullRequestService.test.ts | 4 +- .../ThreadPullRequestService.ts | 11 +- .../ThreadSettlementService.ts | 17 +- .../src/project/AgentSessionScanner.test.ts | 7 +- .../server/src/project/AgentSessionScanner.ts | 14 +- .../src/project/RepositoryIdentityResolver.ts | 11 +- .../PullRequestReviewGuideService.ts | 4 +- .../pullRequest/PullRequestService.test.ts | 2 +- .../src/pullRequest/PullRequestService.ts | 105 +++++---- .../pullRequest/pullRequestSyncKey.test.ts | 4 +- .../src/pullRequest/pullRequestSyncKey.ts | 40 ++-- .../SourceControlProviderRegistry.ts | 6 +- .../src/sourceControl/sourceControlClients.ts | 15 +- apps/server/src/vcs/GitVcsDriverCore.ts | 7 +- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/RightPanelTabs.tsx | 4 +- .../components/chat/ThreadDetailsPrRow.tsx | 4 +- .../pullRequest/PullRequestsPage.tsx | 8 +- .../pullRequestDetail.logic.test.ts | 2 +- .../pullRequest/pullRequestDetail.logic.ts | 3 +- .../pullRequest/pullRequestList.logic.test.ts | 6 +- .../pullRequest/pullRequestList.logic.ts | 12 +- .../pullRequest/usePullRequestListView.ts | 7 +- apps/web/src/hooks/usePullRequestLinking.ts | 3 +- apps/web/src/lib/openPullRequestLink.ts | 4 +- .../src/changeRequestLinks.test.ts | 32 ++- .../client-runtime/src/changeRequestLinks.ts | 12 +- .../src/pullRequestLinkInput.ts | 4 +- .../src/sourceControlClients.test.ts | 114 +++++++++- .../src/sourceControlClients.ts | 15 +- .../src/state/projectGrouping.test.ts | 4 +- packages/contracts/src/environment.ts | 3 +- packages/contracts/src/pullRequest.test.ts | 19 -- packages/contracts/src/pullRequest.ts | 21 -- packages/shared/src/git.test.ts | 21 -- packages/shared/src/git.ts | 73 ++---- packages/shared/src/sourceControl.test.ts | 172 +------------- packages/shared/src/sourceControl.ts | 181 +-------------- .../src/client/definition.test.ts | 91 ++++++++ .../src/client/definition.ts | 84 ++++++- .../src/client/definition.test.ts | 17 ++ .../src/client/definition.ts | 13 +- .../src/server/BitbucketApi.ts | 9 +- .../server/BitbucketPullRequestProvider.ts | 1 + .../src/client/definition.ts | 215 ++++++++++++++++-- .../src/client/threadPullRequests.ts | 14 +- .../src/server/PullRequestProvider.ts | 5 + .../src/client/definition.test.ts | 27 +++ .../src/client/definition.ts | 36 ++- .../src/server/ForgejoPullRequestProvider.ts | 1 + .../ForgejoSourceControlProvider.test.ts | 2 +- .../src/client/definition.test.ts | 15 ++ .../src/client/definition.ts | 9 +- .../src/client/definition.test.ts | 27 +++ .../src/client/definition.ts | 19 +- .../src/server/GitHubPullRequestProvider.ts | 1 + .../src/server/gitHubRepositoryResolution.ts | 15 +- .../src/client/definition.test.ts | 25 ++ .../src/client/definition.ts | 13 +- .../GitLabSourceControlProvider.test.ts | 15 +- .../src/server/GitLabSourceControlProvider.ts | 14 +- 67 files changed, 981 insertions(+), 720 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts index 4d1a4ab5addb..faed80ecc134 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { nativePullRequestDestination } from "../../lib/pullRequestNavigation"; @@ -10,7 +10,7 @@ const project = (id: string, canonicalKey: string, provider: string, remoteUrl: canonicalKey, locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl }, displayName: canonicalKey.split("/").slice(1).join("/"), - provider, + provider: SourceControlProviderKind.make(provider), }, }); const projects = [ diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 4691f81fda89..0e517a53ae9a 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -49,15 +49,12 @@ import { resolveProjectSettings, } from "@t3tools/shared/projectSettings"; import { - detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, - normalizeGitRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { - canonicalRepositoryKey, getChangeRequestTerminologyForKind, isSshRemoteUrl, type ChangeRequestTerminology, @@ -268,7 +265,7 @@ export function pullRequestRepositoryKey(value: string): string | null { url.pathname = match[1]; url.search = ""; url.hash = ""; - return normalizeGitRemoteUrl(url.toString()); + return sourceControlClients.repositoryKeyFromRemote(url.toString()); } catch { return null; } @@ -1027,8 +1024,10 @@ export const make = Effect.gen(function* () { if ( remoteUrl === null || pullRequestKey === null || - canonicalRepositoryKey(pullRequestKey) !== - canonicalRepositoryKey(normalizeGitRemoteUrl(remoteUrl)) + sourceControlClients.canonicalRepositoryKey(pullRequestKey) !== + sourceControlClients.canonicalRepositoryKey( + sourceControlClients.repositoryKeyFromRemote(remoteUrl), + ) ) { return yield* cause; } @@ -1451,7 +1450,7 @@ export const make = Effect.gen(function* () { const baseUrl = remote.remoteUrl === null ? undefined - : detectSourceControlProviderFromGitRemoteUrl(remote.remoteUrl)?.baseUrl; + : sourceControlClients.recognizeRemote(remote.remoteUrl)?.baseUrl; const host = baseUrl === undefined ? null : urlHost(baseUrl); const repository = remote.repositoryNameWithOwner?.toLowerCase(); const fromEnvironment = parseGitHubRepositorySelector(environment.GH_REPO ?? "", host ?? ""); @@ -1485,7 +1484,7 @@ export const make = Effect.gen(function* () { (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? (yield* readConfigValueNullable(cwd, "remote.origin.url")); - const provider = remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; + const provider = remoteUrl ? sourceControlClients.recognizeRemote(remoteUrl) : null; if (!remoteUrl || provider?.kind !== "unknown") return provider; const handle = yield* sourceControlProviders .resolveHandle({ @@ -1516,7 +1515,7 @@ export const make = Effect.gen(function* () { /^https?:\/\//iu.test(remoteUrl) && (repositoryNameWithOwner?.split("/").length ?? 0) > 2 ) { - const kind = detectSourceControlProviderFromGitRemoteUrl(remoteUrl)?.kind; + const kind = sourceControlClients.recognizeRemote(remoteUrl)?.kind; const provider = kind === undefined ? undefined @@ -1527,7 +1526,7 @@ export const make = Effect.gen(function* () { } return { remoteUrl, - remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, + remoteUrlKey: remoteUrl ? sourceControlClients.repositoryKeyFromRemote(remoteUrl) : null, repositoryNameWithOwner, ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), }; @@ -2486,7 +2485,9 @@ export const make = Effect.gen(function* () { remoteName === null ? Effect.succeed(null) : readConfig(`remote.${remoteName}.url`).pipe( - Effect.map((remoteUrl) => (remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null)), + Effect.map((remoteUrl) => + remoteUrl ? sourceControlClients.repositoryKeyFromRemote(remoteUrl) : null, + ), ); const remoteName = remoteNameOverride ?? (yield* readConfig(`branch.${branch}.remote`)); const [headRemoteUrlKey, targetRemoteUrlKey] = diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts index 23af74c8ba22..d4d48bd98619 100644 --- a/apps/server/src/git/linkCreatedPullRequest.test.ts +++ b/apps/server/src/git/linkCreatedPullRequest.test.ts @@ -6,6 +6,7 @@ import { type GitRunStackedActionResult, type OrchestrationV2ServerCommand as OrchestrationCommand, type OrchestrationProjectShell, + SourceControlProviderKind, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -40,7 +41,7 @@ const project: OrchestrationProjectShell = { remoteName: "origin", remoteUrl: "git@github.acme.test:Platform/API.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: "Platform/API", owner: "Platform", name: "API", diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts index 29e80f9c13c4..0d44a31bf9b8 100644 --- a/apps/server/src/git/linkCreatedPullRequest.ts +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -1,10 +1,7 @@ -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { type CommandId, - pullRequestHostOf, type GitRunStackedActionResult, type OrchestrationProjectShell, - type SourceControlProviderKind, type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -35,11 +32,11 @@ export function createdPullRequestKey( return { host: parsed.host, repository: parsed.repository, number: parsed.number, url }; } const identity = project?.repositoryIdentity; - const kind = identity?.provider as SourceControlProviderKind | undefined; - const repository = sourceControlRepositorySelector(identity); + const kind = identity?.provider; + const repository = sourceControlClients.repositorySelector(identity); if (!identity || kind === undefined || repository === null) return null; return { - host: pullRequestHostOf(identity, kind), + host: sourceControlClients.changeRequestHost(identity, kind), repository: repository.toLowerCase(), number, url, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 11bce07bf5ba..d1a0170caf5c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -9,6 +9,7 @@ import { type PullRequestRef, type PullRequestReviewGuideResult, type ThreadPullRequestLink, + SourceControlProviderKind, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -64,7 +65,7 @@ function makeProject( remoteName: "origin", remoteUrl: "git@github.com:T3Tools/T3Code.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: "T3Tools/T3Code", owner: "T3Tools", name: "T3Code", @@ -380,7 +381,7 @@ describe("pull request toolkit handlers", () => { remoteName: "origin", remoteUrl: "git@gitlab.com:group/sub/project.git", }, - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), displayName: "group/sub/project", }), }); @@ -415,7 +416,7 @@ describe("pull request toolkit handlers", () => { remoteName: "origin", remoteUrl: "http://dummy:secret@scm.example.test:8080/tfs/Collection/Project/_git/Repo", }, - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), displayName: "tfs/collection/project/_git/repo", }), }); @@ -592,7 +593,7 @@ describe("pull request guide tools", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/web.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: "acme/web", owner: "acme", name: "web", @@ -651,7 +652,7 @@ describe("pull request guide tools", () => { remoteName: "origin", remoteUrl: "https://dev.azure.com/org/project/_git/web", }, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), displayName: "org/project/_git/web", owner: "org", name: "web", diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index ff323fcdd948..191258ca6693 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -1,13 +1,11 @@ import { CommandId, - pullRequestHostOf, type OrchestrationProjectShell, type OrchestrationV2ThreadShell, type SourceControlProviderKind, type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -61,10 +59,10 @@ function projectHostAndProvider(project: OrchestrationProjectShell | undefined): readonly kind: SourceControlProviderKind | null; } { const identity = project?.repositoryIdentity; - const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null; + const kind = identity?.provider ?? null; if (!identity || kind === null) return { host: null, kind: null }; return { - host: pullRequestHostOf(identity, kind), + host: sourceControlClients.changeRequestHost(identity, kind), kind, }; } @@ -173,7 +171,7 @@ function projectForPullRequest( : pullRequestSyncKey( { projectId: project.id, - repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? "", + repository: sourceControlClients.repositorySelector(project.repositoryIdentity) ?? "", number: 1, }, project.repositoryIdentity, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index b4c323412267..80b17c0e2c26 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -137,7 +137,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/project.git", }, - provider: "github" as const, + provider: SourceControlProviderKind.make("github"), owner: "acme", name: "project", }), @@ -261,7 +261,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/project.git", }, - provider: "github" as const, + provider: SourceControlProviderKind.make("github"), owner: "acme", name: "project", }), diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 2c3f03b0d348..f235ef63e8f4 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -1,7 +1,3 @@ -import { - canonicalRepositoryKey, - sourceControlRepositorySelector, -} from "@t3tools/shared/sourceControl"; import { CommandId, type OrchestrationProjectShell, @@ -29,6 +25,7 @@ import * as ProjectStore from "./ProjectStore.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { forkParked } from "../serverActivation.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as Orchestrator from "./Orchestrator.ts"; class ThreadPullRequestServiceV2 extends Context.Service< @@ -59,8 +56,8 @@ function pullRequestMatchesProject( return ( pullRequest.repositoryKey !== null && project.repositoryIdentity != null && - canonicalRepositoryKey(pullRequest.repositoryKey) === - canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) + sourceControlClients.canonicalRepositoryKey(pullRequest.repositoryKey) === + sourceControlClients.canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) ); } @@ -78,7 +75,7 @@ const resolveProjectForPullRequestDiscovery = Effect.fn( }); return { project: { ...project, repositoryIdentity }, - repository: sourceControlRepositorySelector(repositoryIdentity), + repository: sourceControlClients.repositorySelector(repositoryIdentity), }; }); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 7d95dfd035a3..bf9b46c41a58 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -26,6 +26,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectStore from "./ProjectStore.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -51,18 +52,6 @@ function latestMillis(values: ReadonlyArray): number | null { return latest; } -function canonicalRepositoryKey(key: string): string { - return key - .replace( - /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ) - .replace( - /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ); -} - function pullRequestMatchesProject( pullRequest: GitManager.GitBranchPullRequest, project: { @@ -72,8 +61,8 @@ function pullRequestMatchesProject( return ( pullRequest.repositoryKey !== null && project.repositoryIdentity != null && - canonicalRepositoryKey(pullRequest.repositoryKey) === - canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) + sourceControlClients.canonicalRepositoryKey(pullRequest.repositoryKey) === + sourceControlClients.canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) ); } diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 29a55ec22ba1..baf0f27f9edb 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -745,7 +745,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true }); yield* fileSystem.writeFileString( path.join(submoduleGitDir, "config"), - '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n', + '[remote "origin"]\n\turl = ssh://gitlab.com/pingdotgg/libs/vendor.git\n', ); yield* fileSystem.writeFileString( path.join(submodule, ".git"), @@ -767,7 +767,10 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ).toEqual([ { path: submodule, - git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" }, + git: { + remoteKey: "gitlab.com/pingdotgg/libs/vendor", + repository: "pingdotgg/libs/vendor", + }, }, { path: noRemote, git: { remoteKey: null, repository: null } }, { path: plain, git: null }, diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index e516ce0e53b9..ffdee62c01b5 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -24,20 +24,17 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; -import { - normalizeGitRemoteUrl, - isBareGitConfig, - parseGitHubRepositoryNameWithOwnerFromRemoteUrl, - parseOriginUrlFromGitConfig, -} from "@t3tools/shared/git"; +import { isBareGitConfig, parseOriginUrlFromGitConfig } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as ServerConfig from "../config.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { createTranscriptJsonReader, createTranscriptJsonSelector, @@ -725,8 +722,9 @@ export const make = Effect.gen(function* () { return { _tag: "Repository", git: { - remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), - repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), + remoteKey: + originUrl === null ? null : sourceControlClients.repositoryKeyFromRemote(originUrl), + repository: repositoryPathFromRemoteUrl(originUrl), }, } as const; }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 0ec948b485fc..86a840fec0f4 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -1,9 +1,5 @@ import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts"; import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; -import { - detectSourceControlProviderFromGitRemoteUrl, - normalizeGitRemoteUrl, -} from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -13,6 +9,7 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ProcessRunner from "../processRunner.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { explicitBareRepositoryEnv } from "../vcs/VcsProcess.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -102,7 +99,7 @@ function buildRepositoryOrigin( canonicalKey: string, ): RepositoryIdentity["origin"] { if (!originUrl) return undefined; - const originKey = normalizeGitRemoteUrl(originUrl); + const originKey = sourceControlClients.repositoryKeyFromRemote(originUrl); if (originKey === canonicalKey) return undefined; const displayName = repositoryPathOf(originKey, originUrl); return { canonicalKey: originKey, remoteUrl: originUrl, ...(displayName ? { displayName } : {}) }; @@ -114,8 +111,8 @@ function buildRepositoryIdentity(input: { readonly originUrl: string | undefined; readonly rootPath: string; }): RepositoryIdentity { - const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); - const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); + const canonicalKey = sourceControlClients.repositoryKeyFromRemote(input.remoteUrl); + const sourceControlProvider = sourceControlClients.recognizeRemote(input.remoteUrl); const repositoryPath = repositoryPathOf(canonicalKey, input.remoteUrl); const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); const [owner] = repositoryPathSegments; diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index edd5dd2c7286..e78d850d6834 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -24,7 +24,7 @@ import { } from "@t3tools/contracts"; import type { ReviewGuideStackLayer } from "@t3tools/provider-core/server/textGeneration"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as ServerConfig from "../config.ts"; import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts"; @@ -104,7 +104,7 @@ const make = Effect.gen(function* () { { projectId: ProjectId.make("own"), number: key.number, - repository: sourceControlRepositorySelector(identity) ?? "", + repository: sourceControlClients.repositorySelector(identity) ?? "", }, identity, ); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 1d2b879e11a9..2a07b5715a3a 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -77,7 +77,7 @@ function project(input: { remoteName: "origin", remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`, }, - provider: input.provider ?? "github", + provider: SourceControlProviderKind.make(input.provider ?? "github"), displayName: input.repository, }, } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index c57375c3dd3e..97264c56d133 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,9 +1,4 @@ -import { - canonicalRepositoryKey, - isSshRemoteUrl, - sourceControlRepositorySelector, -} from "@t3tools/shared/sourceControl"; -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { parseDiffFilePaths } from "@t3tools/source-control-core/server/diffFileRevisions"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -23,7 +18,6 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import { PullRequestOperationError, PullRequestUnavailableError, - pullRequestHostOf, pullRequestProviderRequirement, resolvePullRequestAuthorFilter, type OrchestrationProjectShell, @@ -82,7 +76,7 @@ import { type ThreadPullRequestKey, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -500,6 +494,9 @@ function withRateLimitBackoff( ? {} : { mergeMessageRewrite: api.mergeMessageRewrite }), ...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }), + ...(api.readsPublicHostWithoutCheckout === undefined + ? {} + : { readsPublicHostWithoutCheckout: api.readsPublicHostWithoutCheckout }), getViewer: options?.viewerAllowsPause === true ? interactive("getViewer", api.getViewer) @@ -648,6 +645,29 @@ function rankFilterOptions( }; } +/** Whether the project's remote is SSH on a host whose SSH remotes do not name its web host. */ +function sshRemoteHidesWebHost( + identity: NonNullable, +) { + return ( + identity.provider !== undefined && + sourceControlClients.find(identity.provider)?.sshRemoteHidesWebHost === true && + isSshRemoteUrl(identity.locator.remoteUrl) + ); +} + +/** An HTTP remote names its server's web host, port included. */ +function httpRemoteHost(remoteUrl: string): string | null { + try { + const remote = new URL(remoteUrl); + return remote.protocol === "http:" || remote.protocol === "https:" + ? remote.host.toLowerCase() + : null; + } catch { + return null; + } +} + // Capture before the provider read so a slow response keeps its original freshness through caches. const observeRead = Effect.fnUntraced(function* (read: Effect.Effect) { const observedAt = yield* Clock.currentTimeMillis; @@ -682,24 +702,22 @@ export const make = Effect.gen(function* () { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; const identity = project.repositoryIdentity; if ( - (identity?.provider !== "unknown" && - !(identity?.provider === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl))) || - sourceControlRepositorySelector(project.repositoryIdentity) === null + (identity?.provider !== "unknown" && !(identity && sshRemoteHidesWebHost(identity))) || + sourceControlClients.repositorySelector(project.repositoryIdentity) === null ) continue; - const host = pullRequestHostOf(identity, SourceControlProviderKind.make("unknown")); + const host = sourceControlClients.changeRequestHost(identity, "unknown"); if ( filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase() && - pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo")) !== - filter.host.toLowerCase() && + httpRemoteHost(identity.locator.remoteUrl) !== filter.host.toLowerCase() && !isSshRemoteUrl(identity.locator.remoteUrl) ) { continue; } const { remoteName, remoteUrl } = identity.locator; - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); + const provider = sourceControlClients.recognizeRemote(remoteUrl); if (provider !== null) { const candidates = refinements.get(provider.baseUrl); const candidate = { project, provider, remoteName, remoteUrl }; @@ -717,10 +735,9 @@ export const make = Effect.gen(function* () { sourceControlProviders.resolveHandle({ cwd: project.workspaceRoot, context: { - provider: - provider.kind === "forgejo" - ? { ...provider, kind: SourceControlProviderKind.make("unknown") } - : provider, + provider: sourceControlClients.find(provider.kind)?.sshRemoteHidesWebHost + ? { ...provider, kind: SourceControlProviderKind.make("unknown") } + : provider, remoteName, remoteUrl, ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl) @@ -791,22 +808,20 @@ export const make = Effect.gen(function* () { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; const identity = project.repositoryIdentity; - let kind = identity?.provider as SourceControlProviderKind | undefined; - const repository = sourceControlRepositorySelector(project.repositoryIdentity); + let kind = identity?.provider; + const repository = sourceControlClients.repositorySelector(project.repositoryIdentity); if (!identity || kind === undefined || repository === null) continue; let refinedProvider: SourceControlProviderInfo | null | undefined; - if ( - kind === "unknown" || - (kind === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl)) - ) { - const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl); + if (kind === "unknown" || sshRemoteHidesWebHost(identity)) { + const provider = sourceControlClients.recognizeRemote(identity.locator.remoteUrl); refinedProvider = provider === null ? null : refinedProviders.get(provider.baseUrl); kind = refinedProvider?.kind ?? kind; } const host = - refinedProvider?.kind === "forgejo" + refinedProvider && + sourceControlClients.find(refinedProvider.kind)?.sshRemoteHidesWebHost ? new URL(refinedProvider.baseUrl).host.toLowerCase() - : pullRequestHostOf(identity, kind); + : sourceControlClients.changeRequestHost(identity, kind); if (filter.host !== undefined && host !== filter.host.toLowerCase()) { continue; } @@ -833,9 +848,13 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, - remote: repositoryKey ?? normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: + repositoryKey ?? + sourceControlClients.repositoryKeyFromRemote(`https://${host}/${repository}`), repositoryKey: - repositoryKey === null ? null : canonicalRepositoryKey(repositoryKey.toLowerCase()), + repositoryKey === null + ? null + : sourceControlClients.canonicalRepositoryKey(repositoryKey.toLowerCase()), }); } return { supported, unimplemented, viewerRoots }; @@ -866,11 +885,13 @@ export const make = Effect.gen(function* () { }), ); } - const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase()); - // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare - // the complete repository identity before narrowing those checkouts by host. + const referenceKey = `${host}/${repository}`.toLowerCase(); + const repositoryKey = sourceControlClients.canonicalRepositoryKey(referenceKey); + // A host that spells one repository several ways (Azure's SSH and legacy clone hosts) + // can differ from the browser URL's host. Compare the complete repository identity + // before narrowing those checkouts by host. return listWorkspaceProjects( - repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, + sourceControlClients.findByRepositoryKey(referenceKey) ? {} : { host }, ).pipe( Effect.flatMap(({ supported }) => { const onHost = supported.filter((candidate) => candidate.host === host); @@ -887,11 +908,9 @@ export const make = Effect.gen(function* () { onHost.find((candidate) => candidate.repositoryKey === null); if (route === undefined) { return Effect.gen(function* () { - const provider = ["github.com", "bitbucket.org", "codeberg.org"].includes(host) - ? detectSourceControlProviderFromRemoteUrl(`https://${host}/${repository}.git`) - : null; - const api = provider === null ? null : registry.get(provider.kind); - if (api !== null) { + const publicHost = sourceControlClients.findByPublicHost(host); + const api = publicHost === undefined ? null : registry.get(publicHost.kind); + if (api?.readsPublicHostWithoutCheckout) { const selected = yield* projects.getShell(ref.projectId).pipe( Effect.mapError( (cause) => @@ -909,7 +928,9 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, - remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: sourceControlClients.repositoryKeyFromRemote( + `https://${host}/${repository}`, + ), repositoryKey: null, }; } @@ -924,7 +945,9 @@ export const make = Effect.gen(function* () { : { ...route, repository, - remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: sourceControlClients.repositoryKeyFromRemote( + `https://${host}/${repository}`, + ), }, ); }), diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.test.ts b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts index 2b21e20d15fc..83f61338c198 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.test.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts @@ -1,4 +1,4 @@ -import { ProjectId } from "@t3tools/contracts"; +import { ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { pullRequestSyncKey } from "./pullRequestSyncKey.ts"; @@ -13,7 +13,7 @@ describe("pullRequestSyncKey", () => { ])("resolves hostless and checkout-host Azure references for %s", (canonicalKey) => { const identity = { canonicalKey, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), name: "web", displayName: canonicalKey.split("/").slice(1).join("/"), locator: { diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts index 90416e066ca0..681dfb07b9de 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -1,43 +1,37 @@ -import { - pullRequestHostOf, - type PullRequestRef, - type RepositoryIdentity, - SourceControlProviderKind, - type ThreadPullRequestKey, -} from "@t3tools/contracts"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import type { PullRequestRef, RepositoryIdentity, ThreadPullRequestKey } from "@t3tools/contracts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { normalizeThreadPullRequestKey } from "../sourceControl/threadPullRequests.ts"; export function pullRequestSyncKey( reference: PullRequestRef, identity?: RepositoryIdentity | null, ): ThreadPullRequestKey | null { - if (identity?.provider === "azure-devops" && !reference.repository.includes("/")) { + const host = identity + ? sourceControlClients.changeRequestHost(identity, identity.provider) + : undefined; + // A host whose tools select a repository by something other than its path (Azure DevOps takes + // the bare name) can only key such a reference through the project's own repository. + const definition = identity?.provider ? sourceControlClients.find(identity.provider) : undefined; + if (identity && definition?.repositorySelector && !reference.repository.includes("/")) { if ( reference.repository.toLowerCase() !== - sourceControlRepositorySelector(identity)?.toLowerCase() || - (reference.host !== undefined && - reference.host.toLowerCase() !== - pullRequestHostOf(identity, SourceControlProviderKind.make("azure-devops"))) + definition.repositorySelector(identity)?.toLowerCase() || + (reference.host !== undefined && reference.host.toLowerCase() !== host) ) return null; - const [host, ...repository] = identity.canonicalKey.split("/"); - if (!host || repository.length === 0) return null; + const [projectHost, ...repository] = identity.canonicalKey.split("/"); + if (!projectHost || repository.length === 0) return null; return normalizeThreadPullRequestKey({ - host, + host: projectHost, repository: repository.join("/"), number: reference.number, }); } - const host = - reference.host ?? - (identity - ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) - : undefined); - return host === undefined + const resolvedHost = reference.host ?? host; + return resolvedHost === undefined ? null : normalizeThreadPullRequestKey({ - host, + host: resolvedHost, repository: reference.repository, number: reference.number, }); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 6109fe43c19b..5ea6c4037c9c 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -13,9 +13,9 @@ import { type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; import type {} from "@t3tools/contracts"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as BuiltInDrivers from "./builtInDrivers.ts"; +import { sourceControlClients } from "./sourceControlClients.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { probeSourceControlProvider, @@ -144,7 +144,7 @@ function selectProviderContext( ): SourceControlProvider.SourceControlProviderContext | null { const candidates: Array = []; for (const remote of remotes) { - const provider = detectSourceControlProviderFromRemoteUrl(remote.url); + const provider = sourceControlClients.recognizeRemote(remote.url); if (provider) { candidates.push({ provider, @@ -337,7 +337,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit if (input.url.protocol !== "https:" || input.url.username || input.url.password) { return undefined; } - const kind = detectSourceControlProviderFromRemoteUrl(input.url.href)?.kind; + const kind = sourceControlClients.recognizeRemote(input.url.href)?.kind; return kind ? providers.get(kind)?.resolveLink?.(input) : undefined; }, get, diff --git a/apps/server/src/sourceControl/sourceControlClients.ts b/apps/server/src/sourceControl/sourceControlClients.ts index bfe6cc8fecaa..856fe7da39a1 100644 --- a/apps/server/src/sourceControl/sourceControlClients.ts +++ b/apps/server/src/sourceControl/sourceControlClients.ts @@ -10,7 +10,8 @@ import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; * The same order as the clients' registry in `@t3tools/client-runtime/source-control-clients`. * A change request URL goes to the first host whose shape it has, so GitCafe, which claims * `/pull/` and `/pulls/` only on its own hostnames, claims before GitHub and Forgejo, which - * claim them everywhere. + * claim them everywhere. A remote goes to the first host that recognises it, and a hostname can + * carry more than one host's label (`gitea.github.example`), so recognition has its own order. */ export const BUILT_IN_SOURCE_CONTROL_DRIVER_LIST = [ GitHubDriver.driver, @@ -23,5 +24,15 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVER_LIST = [ export const sourceControlClients = makeSourceControlClientRegistry( BUILT_IN_SOURCE_CONTROL_DRIVER_LIST.map((driver) => driver.definition), - { claimChangeRequestUrlsFirst: [GitCafeDriver.driver.kind] }, + { + claimChangeRequestUrlsFirst: [GitCafeDriver.driver.kind], + recognizeRemotesIn: [ + GitCafeDriver.driver.kind, + ForgejoDriver.driver.kind, + GitHubDriver.driver.kind, + GitLabDriver.driver.kind, + AzureDevOpsDriver.driver.kind, + BitbucketDriver.driver.kind, + ], + }, ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b5a523876a58..2bc11cdb0451 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -31,7 +31,7 @@ import { type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -50,6 +50,7 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import * as ServerConfig from "../config.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitProcesses = Semaphore.makeUnsafe(8); @@ -1727,7 +1728,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "ensureRemote", )(function* (input) { const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeGitRemoteUrl(input.url); + const normalizedTargetUrl = sourceControlClients.repositoryKeyFromRemote(input.url); const remoteFetchUrls = yield* runGitStdout( "GitVcsDriver.ensureRemote.listRemoteUrls", input.cwd, @@ -1735,7 +1736,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { + if (sourceControlClients.repositoryKeyFromRemote(remoteUrl) === normalizedTargetUrl) { return remoteName; } } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ec4fe2a5511d..6252b087abe4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnouncement"; @@ -101,7 +102,6 @@ import { import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; import { projectScriptCwd, resolveProjectScripts } from "@t3tools/shared/projectScripts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; @@ -1911,7 +1911,7 @@ export default function ChatView(props: ChatViewProps) { }); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. - const activeProjectRepository = sourceControlRepositorySelector( + const activeProjectRepository = sourceControlClients.repositorySelector( activeProject?.repositoryIdentity, ); const { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index c4577f64fe51..aabd2d5f7ced 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,4 +1,4 @@ -import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; import { @@ -715,7 +715,7 @@ function PullRequestSurfaceIcon({ const host = surface.host ?? (identity?.provider - ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) + ? sourceControlClients.changeRequestHost(identity, identity.provider) : null); const configs = useServerConfigs(); const capabilities = diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 85c65005b9d8..5867b56b3a39 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -1,10 +1,10 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { PullRequestChangeBreakdownRows } from "../pullRequest/PullRequestChangeBreakdownStat"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { selectEnvironmentQueryData } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { ArrowUpRightIcon, EyeIcon, @@ -106,7 +106,7 @@ export function ThreadDetailsPrRow({ const serverConfigs = useServerConfigs(); const supportsPullRequests = serverConfigs.get(environmentId)?.environment.capabilities.pullRequests === true; - const repository = sourceControlRepositorySelector(project?.repositoryIdentity); + const repository = sourceControlClients.repositorySelector(project?.repositoryIdentity); const reference: PullRequestRef | null = supportsPullRequests && project !== null ? linkedReference diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 17a438bd07fe..31362b01f753 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -1,3 +1,4 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { applyPullRequestDraftFilterChange } from "@t3tools/client-runtime/pull-request-query"; import { Spinner } from "~/components/ui/spinner"; import type { PullRequestSpeedActionResult } from "~/components/pullRequest/PullRequestSpeedActions"; @@ -5,7 +6,7 @@ import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullReques import { SidebarPointerSensor } from "~/components/Sidebar.pointer"; import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic"; import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; -import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import type { EnvironmentId, ProjectId, @@ -15,7 +16,6 @@ import type { PullRequestListInput, PullRequestListResult, PullRequestListState, - SourceControlProviderKind, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -1014,9 +1014,9 @@ export function PullRequestsPage() { host: activePullRequestSurface.host ?? (selectedProject?.repositoryIdentity - ? pullRequestHostOf( + ? sourceControlClients.changeRequestHost( selectedProject.repositoryIdentity, - selectedProject.repositoryIdentity.provider as SourceControlProviderKind, + selectedProject.repositoryIdentity.provider, ) : undefined), } diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 292d40bfe400..2883897daf53 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -85,7 +85,7 @@ describe("pull request checkout commands", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/web.git", }, - provider, + provider: SourceControlProviderKind.make(provider), }); it("uses a matching enterprise identity and rejects an explicit host mismatch", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index dd29162fbde4..9dabaea0992e 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -5,7 +5,6 @@ import { GitCommandError, PullRequestDetail, PullRequestOperationError, - pullRequestHostOf, type PullRequestAction, type PullRequestActor, type PullRequestBaseComparison, @@ -135,7 +134,7 @@ export function loadingPullRequestCheckoutCommand( if ( identity?.provider !== undefined && host && - pullRequestHostOf(identity, definition.kind) !== host + sourceControlClients.changeRequestHost(identity, definition.kind) !== host ) { return null; } diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 44cf97bda850..58f6964e4b9e 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -50,7 +50,7 @@ function entry( ) { return { environmentId: "env-1", - provider: "github", + provider: SourceControlProviderKind.make("github"), host: "github.com", projectId: "project-1", projectTitle: "t3code", @@ -752,7 +752,7 @@ describe("findProjectForRepository", () => { repositoryIdentity: { canonicalKey: `dev.azure.com/${path}`, locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), displayName: path, owner: "contoso", name: path.split("/").at(-1)!, @@ -788,7 +788,7 @@ describe("findProjectForRepository", () => { repositoryIdentity: { canonicalKey: `github.com/${repository}`, locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: repository, }, }); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index fbd70381f87d..656913257a11 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1,3 +1,4 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import * as Schema from "effect/Schema"; import { @@ -5,13 +6,11 @@ import { PullRequestListEntry, PullRequestListProjectError, PullRequestListResult, - pullRequestHostOf, resolvePullRequestAuthorFilter, } from "@t3tools/contracts"; import type { ProjectId, RepositoryIdentity, - SourceControlProviderKind, PullRequestAction, PullRequestActor, PullRequestDiffStat, @@ -23,8 +22,6 @@ import type { PullRequestState, } from "@t3tools/contracts"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; - import { toSortableTimestamp } from "../../lib/threadSort"; import type { PullRequestListSort } from "./pullRequestListPreferences"; @@ -1078,12 +1075,15 @@ export function findProjectForRepository< return false; } const identity = project.repositoryIdentity; - if (!identity || sourceControlRepositorySelector(identity)?.toLowerCase() !== repository) { + if ( + !identity || + sourceControlClients.repositorySelector(identity)?.toLowerCase() !== repository + ) { return false; } return ( host === undefined || - pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) === host + sourceControlClients.changeRequestHost(identity, identity.provider) === host ); }); // Azure repository names can collide across projects on the same host. diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts index 347a00b628b9..2629fb6a34b3 100644 --- a/apps/web/src/components/pullRequest/usePullRequestListView.ts +++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts @@ -1,13 +1,12 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { parsePullRequestQuery } from "@t3tools/client-runtime/pull-request-query"; import { - pullRequestHostOf, type EnvironmentId, type ProjectId, type PullRequestInvolvement, type PullRequestListFilters, type PullRequestListResult, type PullRequestListState, - type SourceControlProviderKind, } from "@t3tools/contracts"; import { useState } from "react"; @@ -156,9 +155,9 @@ export function usePullRequestScope(search: PullRequestsSearch) { const expectedHosts = (() => { const byHost = new Map(); for (const project of projects) { - const kind = project.repositoryIdentity?.provider as SourceControlProviderKind | undefined; + const kind = project.repositoryIdentity?.provider; if (kind === undefined) continue; - const host = pullRequestHostOf(project.repositoryIdentity, kind); + const host = sourceControlClients.changeRequestHost(project.repositoryIdentity, kind); if (!byHost.has(host)) byHost.set(host, { host, kind }); } return [...byHost.values()]; diff --git a/apps/web/src/hooks/usePullRequestLinking.ts b/apps/web/src/hooks/usePullRequestLinking.ts index 18c31cd3714d..98ed5462cf5a 100644 --- a/apps/web/src/hooks/usePullRequestLinking.ts +++ b/apps/web/src/hooks/usePullRequestLinking.ts @@ -1,4 +1,3 @@ -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import type { EnvironmentId, ScopedThreadRef, @@ -101,7 +100,7 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde reference: { ...parsed, url }, legacyProjectId: legacyProject?.id ?? null, legacyRepository: - sourceControlRepositorySelector(legacyProject?.repositoryIdentity) ?? undefined, + sourceControlClients.repositorySelector(legacyProject?.repositoryIdentity) ?? undefined, linked, }); if (mutation === null) diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 0d3361e73de2..8c9a41998641 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -11,7 +11,6 @@ import { sourceControlClients, type ChangeRequestLink, } from "@t3tools/client-runtime/source-control-clients"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { useOpenLink } from "../browser/useOpenLink"; import { useClientSettings } from "../hooks/useSettings"; @@ -117,7 +116,8 @@ export function useOpenChangeRequestLink( const repository = serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === true ? parsed.repository - : (sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository); + : (sourceControlClients.repositorySelector(project.repositoryIdentity) ?? + parsed.repository); event.preventDefault(); event.stopPropagation(); if (resolvedPanelRef) { diff --git a/packages/client-runtime/src/changeRequestLinks.test.ts b/packages/client-runtime/src/changeRequestLinks.test.ts index f5d39305da2e..be25a3e5b10b 100644 --- a/packages/client-runtime/src/changeRequestLinks.test.ts +++ b/packages/client-runtime/src/changeRequestLinks.test.ts @@ -1,4 +1,9 @@ -import { EnvironmentId, ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + type RepositoryIdentity, + SourceControlProviderKind, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -15,7 +20,7 @@ describe("findProjectForChangeRequest", () => { const projects = [ project({ canonicalKey: "gitlab.com/t3tools/platform/t3code", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), displayName: "t3tools/platform/t3code", owner: "t3tools", name: "t3code", @@ -34,7 +39,7 @@ describe("findProjectForChangeRequest", () => { const projects = [ project({ canonicalKey: "github.com/pingdotgg/t3code", - provider: "github", + provider: SourceControlProviderKind.make("github"), owner: "pingdotgg", name: "t3code", }), @@ -56,7 +61,7 @@ describe("resolvePullRequestPreviewTarget", () => { environmentId, repositoryIdentity: { canonicalKey: "github.com/acme/web", - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: "acme/web", locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@github.com:acme/web" }, } satisfies RepositoryIdentity, @@ -81,7 +86,7 @@ describe("resolvePullRequestPreviewTarget", () => { describe("projectChangeRequestUrl", () => { const identity = (provider: string): RepositoryIdentity => ({ canonicalKey: "github.com/acme/default", - provider, + provider: SourceControlProviderKind.make(provider), locator: { source: "git-remote", remoteName: "origin", @@ -95,6 +100,23 @@ describe("projectChangeRequestUrl", () => { ); }); + it("builds the project's own Azure repository from its full path, not its short selector", () => { + const azure: RepositoryIdentity = { + canonicalKey: "dev.azure.com/org/project/_git/web", + provider: SourceControlProviderKind.make("azure-devops"), + owner: "org", + name: "web", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://dev.azure.com/org/project/_git/web", + }, + }; + expect(projectChangeRequestUrl(azure, "web", 42)).toBe( + "https://dev.azure.com/org/project/_git/web/pullrequest/42", + ); + }); + it("builds nothing without a project host that knows its URL shape", () => { expect(projectChangeRequestUrl(null, "acme/repository", 1)).toBeNull(); expect(projectChangeRequestUrl(identity("unknown"), "acme/repository", 1)).toBeNull(); diff --git a/packages/client-runtime/src/changeRequestLinks.ts b/packages/client-runtime/src/changeRequestLinks.ts index 36c9097c951e..3786a9eff06e 100644 --- a/packages/client-runtime/src/changeRequestLinks.ts +++ b/packages/client-runtime/src/changeRequestLinks.ts @@ -5,7 +5,6 @@ import type { RepositoryIdentity, } from "@t3tools/contracts"; import type { ChangeRequestLink } from "@t3tools/source-control-core/client/definition"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { sourceControlClients } from "./sourceControlClients.ts"; @@ -53,7 +52,8 @@ export function resolvePullRequestPreviewTarget({ input: { projectId: project.id, host: parsed.authority ?? parsed.host, - repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, + repository: + sourceControlClients.repositorySelector(project.repositoryIdentity) ?? parsed.repository, number: parsed.number, }, }; @@ -66,12 +66,16 @@ export function projectChangeRequestUrl( number: number, ): string | null { if (identity?.provider === undefined) return null; - const host = identity.canonicalKey.split("/")[0]; + const [host, ...path] = identity.canonicalKey.split("/"); if (!host) return null; + // The RPC selector can be shorter than the browser path (Azure's `web` for + // `org/project/_git/web`), so the project's own repository uses its full key. + const ownRepository = sourceControlClients.repositorySelector(identity) === repository; return sourceControlClients.get(identity.provider).changeRequestUrl({ host, - repository, + repository: ownRepository ? path.join("/") : repository, number, remoteUrl: identity.locator.remoteUrl.trim(), + ...(ownRepository && identity.webUrl !== undefined ? { webUrl: identity.webUrl } : {}), }); } diff --git a/packages/client-runtime/src/pullRequestLinkInput.ts b/packages/client-runtime/src/pullRequestLinkInput.ts index c897dc458878..30c9e3e89caa 100644 --- a/packages/client-runtime/src/pullRequestLinkInput.ts +++ b/packages/client-runtime/src/pullRequestLinkInput.ts @@ -1,4 +1,4 @@ -import { pullRequestHostOf, type RepositoryIdentity } from "@t3tools/contracts"; +import type { RepositoryIdentity } from "@t3tools/contracts"; import { sourceControlClients } from "./sourceControlClients.ts"; @@ -42,7 +42,7 @@ export function pullRequestLinkProject(identity: RepositoryIdentity | null | und (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); if (repository === null) return null; const definition = sourceControlClients.get(identity.provider ?? "unknown"); - const host = pullRequestHostOf(identity, definition.kind); + const host = sourceControlClients.changeRequestHost(identity, definition.kind); return { host, repository, diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts index 4a646bc9caba..9a2cb20e4a47 100644 --- a/packages/client-runtime/src/sourceControlClients.test.ts +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -1,4 +1,4 @@ -import { ProjectId } from "@t3tools/contracts"; +import { ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { sourceControlClients } from "./sourceControlClients.ts"; @@ -238,3 +238,115 @@ describe("change request URLs built from a parsed link", () => { }); }); }); + +describe("sourceControlClients.recognizeRemote", () => { + const kindOfRemote = (url: string) => sourceControlClients.recognizeRemote(url)?.kind ?? null; + + it("asks hosts in a fixed order when one hostname carries several hosts' labels", () => { + expect(kindOfRemote("https://gitea.github.example/o/r")).toBe("forgejo"); + expect(kindOfRemote("https://forgejo.gitlab.example/o/r")).toBe("forgejo"); + expect(kindOfRemote("https://gitlab.github.example/o/r")).toBe("github"); + expect(kindOfRemote("https://bitbucket.gitlab.example/o/r")).toBe("gitlab"); + expect(kindOfRemote("https://bitbucket.visualstudio.com/p/_git/r")).toBe("azure-devops"); + expect(kindOfRemote("git@git.cafe:pingdotgg/t3code.git")).toBe("gitcafe"); + }); + + it("reads a remote no host claims as an unknown host, and a local path as no host", () => { + expect( + sourceControlClients.recognizeRemote("https://self-hosted.example.test:8443/group/repo.git"), + ).toEqual({ + kind: "unknown", + name: "self-hosted.example.test:8443", + baseUrl: "https://self-hosted.example.test:8443", + }); + expect(sourceControlClients.recognizeRemote("git@github-work:owner/repo.git")).toEqual({ + kind: "unknown", + name: "github-work", + baseUrl: "https://github-work", + }); + for (const local of ["/srv/git/repo.git", "C:/repos/project", "file:///srv/git/repo.git", ""]) { + expect(sourceControlClients.recognizeRemote(local), local).toBeNull(); + } + }); +}); + +describe("sourceControlClients repository keys", () => { + // Persisted as `repositoryIdentity.canonicalKey` and in thread pull request keys. + it.each([ + ["git@github.com:T3Tools/T3Code.git", "github.com/t3tools/t3code"], + ["ssh://git@gitlab.company.com:2222/team/project.git", "gitlab.company.com/team/project"], + [ + "git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code", + "dev.azure.com/t3tools/platform/_git/t3code", + ], + [ + "T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code", + "t3tools.visualstudio.com/platform/_git/t3code", + ], + [ + "https://T3Tools.visualstudio.com/DefaultCollection/Platform/_git/T3Code", + "t3tools.visualstudio.com/defaultcollection/platform/_git/t3code", + ], + ["git@ssh.dev.azure.com:v3/o/p", "ssh.dev.azure.com/v3/o/p"], + ["ssh.dev.azure.com/v3/o/p/r", "ssh.dev.azure.com/v3/o/p/r"], + ["C:/repos/project", "c:/repos/project"], + ])("keys %s as %s", (remote, key) => { + expect(sourceControlClients.repositoryKeyFromRemote(remote)).toBe(key); + }); + + it("compares Azure DevOps spellings on dev.azure.com and leaves other keys alone", () => { + expect( + sourceControlClients.canonicalRepositoryKey( + "org.visualstudio.com/defaultcollection/project/_git/repo", + ), + ).toBe("dev.azure.com/org/project/_git/repo"); + expect(sourceControlClients.findByRepositoryKey("dev.azure.com/o/r")?.kind).toBe( + "azure-devops", + ); + expect(sourceControlClients.canonicalRepositoryKey("tfs.corp.example/c/p/_git/r")).toBe( + "tfs.corp.example/c/p/_git/r", + ); + expect(sourceControlClients.findByRepositoryKey("github.com/o/r")).toBeUndefined(); + }); +}); + +describe("sourceControlClients project hosts", () => { + const identity = { + canonicalKey: "forge.example/team/repo", + locator: { remoteUrl: "http://forge.example:3000/team/repo.git" }, + }; + + it("keeps an HTTP remote's port for Forgejo only, and falls back to the kind without a key", () => { + expect(sourceControlClients.changeRequestHost(identity, "forgejo")).toBe("forge.example:3000"); + expect(sourceControlClients.changeRequestHost(identity, "gitlab")).toBe("forge.example"); + expect(sourceControlClients.changeRequestHost(identity, undefined)).toBe("forge.example"); + expect( + sourceControlClients.changeRequestHost( + { locator: { remoteUrl: "https://x.example/a/b" } }, + "github", + ), + ).toBe("github"); + }); + + it("selects a repository by its path except where the host's tools take a name", () => { + const repository = { + displayName: "group/subgroup/service", + owner: "group", + name: "service", + }; + expect(sourceControlClients.repositorySelector(repository)).toBe("group/subgroup/service"); + expect( + sourceControlClients.repositorySelector({ + ...repository, + provider: SourceControlProviderKind.make("gitlab"), + }), + ).toBe("group/subgroup/service"); + expect( + sourceControlClients.repositorySelector({ + ...repository, + provider: SourceControlProviderKind.make("azure-devops"), + }), + ).toBe("service"); + expect(sourceControlClients.repositorySelector({ owner: "o", name: "r" })).toBe("o/r"); + }); +}); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index 7faf3abcc903..ac67c682c520 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -22,7 +22,8 @@ import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; * GitHub comes first because a repository that has not reported its host yet reads as GitHub, * which is what clients have always shown there. A change request URL goes to the first host * whose shape it has, so GitCafe, which claims `/pull/` and `/pulls/` only on its own hostnames, - * claims before GitHub and Forgejo, which claim them everywhere. The server's + * claims before GitHub and Forgejo, which claim them everywhere. A hostname can carry more than + * one host's label (`gitea.github.example`), so remote recognition has its own order. The server's * `sourceControl/sourceControlClients.ts` keeps the same order. */ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ @@ -36,7 +37,17 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ export const sourceControlClients = makeSourceControlClientRegistry( BUILT_IN_SOURCE_CONTROL_CLIENTS, - { claimChangeRequestUrlsFirst: [GitCafe.definition.kind] }, + { + claimChangeRequestUrlsFirst: [GitCafe.definition.kind], + recognizeRemotesIn: [ + GitCafe.definition.kind, + Forgejo.definition.kind, + GitHub.definition.kind, + GitLab.definition.kind, + AzureDevOps.definition.kind, + Bitbucket.definition.kind, + ], + }, ); export type { GitHubSettings } from "@t3tools/source-control-github/client/definition"; diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 8ca5ed4dea09..824fb87e3c40 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "./models.ts"; @@ -84,7 +84,7 @@ const repositoryIdentity = { remoteName: "upstream", remoteUrl: "https://github.com/t3tools/t3code.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), owner: "t3tools", name: "t3code", displayName: "T3 Code", diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 18d09c304a83..c03b57d2756a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -8,6 +8,7 @@ import { ThreadId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; +import { SourceControlProviderKind } from "./sourceControl.ts"; export const ORCHESTRATION_PROTOCOL_VERSION = 2; export const ORCHESTRATION_PROTOCOL_VERSION_TEXT = "2"; @@ -186,7 +187,7 @@ export const RepositoryIdentity = Schema.Struct({ /** Work tree root, or the git dir for a bare repository. */ rootPath: Schema.optionalKey(TrimmedNonEmptyString), displayName: Schema.optionalKey(TrimmedNonEmptyString), - provider: Schema.optionalKey(TrimmedNonEmptyString), + provider: Schema.optionalKey(SourceControlProviderKind), owner: Schema.optionalKey(TrimmedNonEmptyString), name: Schema.optionalKey(TrimmedNonEmptyString), origin: Schema.optionalKey(RepositoryOrigin), diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index bf9d359b0e3d..7c56ca8b9df3 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from "vite-plus/test"; import { PullRequestDetail, PullRequestListResult, - pullRequestHostOf, resolvePullRequestAuthorFilter, } from "./pullRequest.ts"; @@ -61,24 +60,6 @@ const LIST_RESULT: PullRequestListResult = { }; describe("PullRequestListResult", () => { - it("separates Forgejo HTTP ports while preserving other provider host identities", () => { - const identity = { - canonicalKey: "forge.example/team/repo", - locator: { remoteUrl: "http://forge.example:3000/team/repo.git" }, - }; - expect(pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo"))).toBe( - "forge.example:3000", - ); - expect(pullRequestHostOf(identity, SourceControlProviderKind.make("gitlab"))).toBe( - "forge.example", - ); - expect( - pullRequestHostOf( - { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } }, - SourceControlProviderKind.make("forgejo"), - ), - ).toBe("forge.example"); - }); /** * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record * with an optional value, for one — fails as an interrupted request rather than as a schema diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index bf8f124c3606..7e371e867722 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -938,27 +938,6 @@ const PROVIDER_REQUIREMENT = new Map< ], ]); -export function pullRequestHostOf( - identity: - | { - readonly canonicalKey?: string | undefined; - readonly locator?: { readonly remoteUrl: string } | undefined; - } - | null - | undefined, - kind: SourceControlProviderKind, -): string { - if (kind === "forgejo") { - try { - const remote = new URL(identity?.locator?.remoteUrl ?? ""); - if (remote.protocol === "http:" || remote.protocol === "https:") - return remote.host.toLowerCase(); - } catch {} - } - const host = identity?.canonicalKey?.split("/")[0]?.trim(); - return host === undefined || host.length === 0 ? kind : host.toLowerCase(); -} - /** * `author:me` names whoever is signed in to the host being read, GitHub's `@me` spelled either * way. A host's own search would answer it, but the page and the server both re-check an author diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 91d01cd3a18f..adf44d2289e4 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -62,27 +62,6 @@ describe("normalizeGitRemoteUrl", () => { expect(normalizeGitRemoteUrl("C:/repos/project")).toBe("c:/repos/project"); expect(normalizeGitRemoteUrl("git@x:team/project.git")).toBe("x/team/project"); }); - - it("gives an Azure DevOps repository the same key over SSH as over HTTPS", () => { - expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code")).toBe( - "dev.azure.com/t3tools/platform/_git/t3code", - ); - expect(normalizeGitRemoteUrl("ssh://git@ssh.dev.azure.com:22/v3/T3Tools/Platform/T3Code")).toBe( - "dev.azure.com/t3tools/platform/_git/t3code", - ); - expect( - normalizeGitRemoteUrl("https://T3Tools@dev.azure.com/T3Tools/Platform/_git/T3Code"), - ).toBe("dev.azure.com/t3tools/platform/_git/t3code"); - }); - - it("puts the organization back in the host on the name dev.azure.com replaced", () => { - expect( - normalizeGitRemoteUrl("T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code"), - ).toBe("t3tools.visualstudio.com/platform/_git/t3code"); - expect(normalizeGitRemoteUrl("https://T3Tools.visualstudio.com/Platform/_git/T3Code")).toBe( - "t3tools.visualstudio.com/platform/_git/t3code", - ); - }); }); describe("parseOriginUrlFromGitConfig", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 32b7fbf88585..7bf954cc3d8f 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,7 +1,6 @@ import type { BranchNamingOptions, VcsRef, - SourceControlProviderInfo, VcsStatusLocalResult, VcsStatusRemoteResult, VcsStatusResult, @@ -9,7 +8,6 @@ import type { } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3"; const TEMP_WORKTREE_HEX_TOKEN = "[0-9a-f]{8}"; @@ -125,41 +123,32 @@ export function isTemporaryWorktreeBranch(refName: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); } -/** - * Azure alone addresses one repository under two names that share no part: `ssh.dev.azure.com` and - * `v3/{org}/{project}/{repo}` over SSH, against `dev.azure.com` and `{org}/{project}/_git/{repo}` - * everywhere a person sees it. A project cloned over SSH would otherwise be a different repository - * to every comparison made against a pull request URL, which arrives in the web spelling. - */ -function azureDevOpsRepositoryKey(host: string, segments: ReadonlyArray): string | null { - if (host !== "ssh.dev.azure.com" && host !== "vs-ssh.visualstudio.com") return null; - const [marker, organization, project, repository] = segments; - if (segments.length !== 4 || marker !== "v3") return null; - if (!organization || !project || !repository) return null; - return host === "ssh.dev.azure.com" - ? `dev.azure.com/${organization}/${project}/_git/${repository}` - : `${organization}.visualstudio.com/${project}/_git/${repository}`; -} - -export function normalizeGitRemoteUrl(value: string): string { - const normalized = value +function lowerCaseRemote(value: string): string { + return value .trim() .replace(/\/+$/g, "") .replace(/\.git$/i, "") .toLowerCase(); +} + +/** + * The host and repository path a remote names, lower case and without a port, or null for a + * remote that names no host, such as a local path. + */ +export function parseGitRemoteRepository( + value: string, +): { readonly host: string; readonly path: string } | null { + const normalized = lowerCaseRemote(value); if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { try { const url = new URL(normalized); const repositorySegments = url.pathname.split("/").filter((segment) => segment.length > 0); if (url.hostname && repositorySegments.length > 1) { - return ( - azureDevOpsRepositoryKey(url.hostname, repositorySegments) ?? - `${url.hostname}/${repositorySegments.join("/")}` - ); + return { host: url.hostname, path: repositorySegments.join("/") }; } } catch { - return normalized; + return null; } } @@ -169,11 +158,17 @@ export function normalizeGitRemoteUrl(value: string): string { : /^(?:[a-zA-Z0-9._-]+@)?([^@:/\\\s]+):\/?([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); const scpHost = scpStyleHostAndPath?.[1]; const scpPath = scpStyleHostAndPath?.[2]; - if (scpHost && scpPath) { - return azureDevOpsRepositoryKey(scpHost, scpPath.split("/")) ?? `${scpHost}/${scpPath}`; - } + return scpHost && scpPath ? { host: scpHost, path: scpPath } : null; +} - return normalized; +/** + * `host/path` for a remote that names a host, otherwise the remote itself, lower case. A host that + * spells repositories differently over SSH has its source control registry's + * `repositoryKeyFromRemote` rewrite this, so persisted keys come from that, not from here. + */ +export function normalizeGitRemoteUrl(value: string): string { + const repository = parseGitRemoteRepository(value); + return repository === null ? lowerCaseRemote(value) : `${repository.host}/${repository.path}`; } function parseGitConfigValue(raw: string): string { @@ -251,20 +246,6 @@ export function parseOriginUrlFromGitConfig(configText: string): string | null { return originUrl ?? firstRemoteUrl; } -export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { - const trimmed = url?.trim() ?? ""; - if (trimmed.length === 0) { - return null; - } - - const match = - /^(?:git@github\.com:|ssh:\/\/(?:git@)?github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( - trimmed, - ); - const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; - return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; -} - function deriveLocalBranchNameCandidatesFromRemoteRef( branchName: string, remoteName?: string, @@ -318,12 +299,6 @@ export function dedupeRemoteBranchesWithLocalMatches( }); } -export function detectSourceControlProviderFromGitRemoteUrl( - remoteUrl: string, -): SourceControlProviderInfo | null { - return detectSourceControlProviderFromRemoteUrl(remoteUrl); -} - const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = { hasUpstream: false, aheadCount: 0, diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 23d0c8b4ba81..bb3ad5716fdb 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -1,171 +1,11 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; -import { - sourceControlRepositorySelector, - detectSourceControlProviderFromRemoteUrl, - getChangeRequestTerminologyForKind, -} from "./sourceControl.ts"; +import { getChangeRequestTerminologyForKind } from "./sourceControl.ts"; -describe("detectSourceControlProviderFromRemoteUrl", () => { - it("detects Forgejo and Gitea hosts while preserving HTTP origins", () => { - for (const host of ["codeberg.org", "forgejo.example.test", "gitea.example.test"]) { - expect(detectSourceControlProviderFromRemoteUrl(`http://${host}:3000/team/repo.git`)).toEqual( - { - kind: "forgejo", - name: "Forgejo", - baseUrl: `http://${host}:3000`, - }, - ); - } - expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("forgejo"))).toEqual({ - shortLabel: "PR", - singular: "pull request", - }); +it("calls a Forgejo change request a pull request", () => { + expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("forgejo"))).toEqual({ + shortLabel: "PR", + singular: "pull request", }); - - it("detects Azure DevOps SSH remotes", () => { - expect( - detectSourceControlProviderFromRemoteUrl("git@ssh.dev.azure.com:v3/org/project/repo")?.kind, - ).toBe("azure-devops"); - expect( - detectSourceControlProviderFromRemoteUrl("ssh://git@ssh.dev.azure.com:22/v3/org/project/repo") - ?.kind, - ).toBe("azure-devops"); - expect( - detectSourceControlProviderFromRemoteUrl("git@vs-ssh.visualstudio.com:v3/org/project/repo") - ?.kind, - ).toBe("azure-devops"); - }); - - it("preserves ports while classifying by hostname", () => { - expect( - detectSourceControlProviderFromRemoteUrl("https://gitlab.com:8443/group/repo.git"), - ).toEqual({ - kind: "gitlab", - name: "GitLab", - baseUrl: "https://gitlab.com:8443", - }); - expect( - detectSourceControlProviderFromRemoteUrl( - "https://self-hosted.example.test:8443/group/repo.git", - ), - ).toEqual({ - kind: "unknown", - name: "self-hosted.example.test:8443", - baseUrl: "https://self-hosted.example.test:8443", - }); - }); - - it("does not reuse SSH ports for HTTPS provider URLs", () => { - expect( - detectSourceControlProviderFromRemoteUrl("ssh://git@gitlab.example.test:24/group/repo.git"), - ).toEqual({ - kind: "gitlab", - name: "GitLab Self-Hosted", - baseUrl: "https://gitlab.example.test", - }); - expect( - detectSourceControlProviderFromRemoteUrl("ssh://git@code.example.test:24/team/project.git"), - ).toEqual({ - kind: "unknown", - name: "code.example.test", - baseUrl: "https://code.example.test", - }); - }); - - it("matches self-hosted providers by complete DNS labels", () => { - expect( - detectSourceControlProviderFromRemoteUrl("https://github.example.com/owner/repo.git")?.kind, - ).toBe("github"); - expect( - detectSourceControlProviderFromRemoteUrl("https://gitlab.example.com/group/repo.git")?.kind, - ).toBe("gitlab"); - expect( - detectSourceControlProviderFromRemoteUrl("https://bitbucket.example.com/workspace/repo.git") - ?.kind, - ).toBe("bitbucket"); - }); - - it("detects GitHub Enterprise Cloud data residency hosts on ghe.com", () => { - expect(detectSourceControlProviderFromRemoteUrl("https://acme.ghe.com/owner/repo.git")).toEqual( - { - kind: "github", - name: "GitHub Self-Hosted", - baseUrl: "https://acme.ghe.com", - }, - ); - expect(detectSourceControlProviderFromRemoteUrl("git@acme.ghe.com:owner/repo.git")?.kind).toBe( - "github", - ); - expect(detectSourceControlProviderFromRemoteUrl("https://ghe.com/owner/repo.git")?.kind).toBe( - "unknown", - ); - }); - - it("does not match provider names embedded in unrelated DNS labels", () => { - expect( - detectSourceControlProviderFromRemoteUrl("https://notgithub.example.com/owner/repo.git") - ?.kind, - ).toBe("unknown"); - expect( - detectSourceControlProviderFromRemoteUrl("https://notgitlab.example.com/group/repo.git") - ?.kind, - ).toBe("unknown"); - expect( - detectSourceControlProviderFromRemoteUrl( - "https://notbitbucket.example.com/workspace/repo.git", - )?.kind, - ).toBe("unknown"); - }); - - it("detects SSH remotes with non-git SSH users (e.g. gitlab@, deploy@)", () => { - expect( - detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git")?.kind, - ).toBe("gitlab"); - expect( - detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git") - ?.baseUrl, - ).toBe("https://gitlab.example.com"); - expect(detectSourceControlProviderFromRemoteUrl("deploy@github.com:owner/repo.git")?.kind).toBe( - "github", - ); - expect( - detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, - ).toBe("bitbucket"); - }); -}); - -it("names an Azure DevOps repository by its own name, not its project path", () => { - // `az repos pr list --repository` takes a name and detects the organisation and project from - // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then - // reads as unavailable on the page. - const selector = sourceControlRepositorySelector({ - provider: "azure-devops", - displayName: "contoso/payments/_git/checkout", - owner: "contoso", - name: "checkout", - }); - expect(selector).toBe("checkout"); -}); - -it("decodes an Azure DevOps repository name that the remote URL percent-encodes", () => { - // A remote spells a space as `%20`, and `az repos pr list --repository` reads that literally. - const selector = sourceControlRepositorySelector({ - provider: "azure-devops", - displayName: "contoso/payments%20(eu)/_git/checkout%20(eu)", - owner: "contoso", - name: "checkout%20(eu)", - }); - expect(selector).toBe("checkout (eu)"); -}); - -it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { - const selector = sourceControlRepositorySelector({ - provider: "gitlab", - displayName: "group/subgroup/service", - owner: "group", - name: "service", - }); - expect(selector).toBe("group/subgroup/service"); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 5729d2015014..f7039961f975 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,5 +1,4 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; -import type { RepositoryIdentity, SourceControlProviderInfo } from "@t3tools/contracts"; +import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; export interface ChangeRequestPresentation { readonly icon: @@ -143,181 +142,3 @@ export function isSshRemoteUrl(remoteUrl: string): boolean { const trimmed = remoteUrl.trim(); return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://"); } - -function parseRemoteHost(remoteUrl: string): string | null { - const trimmed = remoteUrl.trim(); - if (trimmed.length === 0) { - return null; - } - - const scpMatch = SCP_SSH_REMOTE_PATTERN.exec(trimmed); - if (scpMatch?.[1]) { - return scpMatch[1].toLowerCase(); - } - - try { - const url = new URL(trimmed); - return (url.protocol === "ssh:" ? url.hostname : url.host).toLowerCase(); - } catch { - return null; - } -} - -function parseHostName(host: string): string { - try { - return new URL(`https://${host}`).hostname.toLowerCase(); - } catch { - return host.replace(/:\d+$/u, "").toLowerCase(); - } -} - -function toBaseUrl(host: string): string { - return `https://${host}`; -} - -function hasDnsLabel(host: string, label: string): boolean { - return host.split(".").includes(label); -} - -/** `*.ghe.com` is GitHub Enterprise Cloud with data residency, one subdomain per enterprise. */ -function isGitHubHost(host: string): boolean { - return host === "github.com" || host.endsWith(".ghe.com") || hasDnsLabel(host, "github"); -} - -function isGitLabHost(host: string): boolean { - return host === "gitlab.com" || hasDnsLabel(host, "gitlab"); -} - -function isAzureDevOpsHost(host: string): boolean { - // `ssh.dev.azure.com` is the default Azure DevOps SSH clone host - // (git@ssh.dev.azure.com:v3/org/project/repo), so match any `*.dev.azure.com` - // subdomain, not just the bare `dev.azure.com`. Legacy hosts stay under - // `.visualstudio.com` (including `vs-ssh.visualstudio.com`). - return ( - host === "dev.azure.com" || - host.endsWith(".dev.azure.com") || - host.endsWith(".visualstudio.com") - ); -} - -function isGitCafeHost(host: string): boolean { - return host === "git.cafe" || host === "staging.git.cafe"; -} - -function isBitbucketHost(host: string): boolean { - return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); -} - -export function detectSourceControlProviderFromRemoteUrl( - remoteUrl: string, -): SourceControlProviderInfo | null { - const host = parseRemoteHost(remoteUrl); - if (!host) { - return null; - } - const hostname = parseHostName(host); - - if (isGitCafeHost(hostname)) { - return { - kind: SourceControlProviderKind.make("gitcafe"), - name: "GitCafe", - baseUrl: toBaseUrl(hostname), - }; - } - - if ( - hostname === "codeberg.org" || - hasDnsLabel(hostname, "forgejo") || - hasDnsLabel(hostname, "gitea") - ) { - return { - kind: SourceControlProviderKind.make("forgejo"), - name: "Forgejo", - baseUrl: /^https?:/iu.test(remoteUrl.trim()) - ? new URL(remoteUrl.trim()).origin - : toBaseUrl(host), - }; - } - - if (isGitHubHost(hostname)) { - return { - kind: SourceControlProviderKind.make("github"), - name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", - baseUrl: toBaseUrl(host), - }; - } - - if (isGitLabHost(hostname)) { - return { - kind: SourceControlProviderKind.make("gitlab"), - name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", - baseUrl: toBaseUrl(host), - }; - } - - if (isAzureDevOpsHost(hostname)) { - return { - kind: SourceControlProviderKind.make("azure-devops"), - name: "Azure DevOps", - baseUrl: toBaseUrl(host), - }; - } - - if (isBitbucketHost(hostname)) { - return { - kind: SourceControlProviderKind.make("bitbucket"), - name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", - baseUrl: toBaseUrl(host), - }; - } - - return { - kind: SourceControlProviderKind.make("unknown"), - name: host, - baseUrl: toBaseUrl(host), - }; -} - -function decodeUrlPathSegment(segment: string): string { - try { - return decodeURIComponent(segment); - } catch { - return segment; - } -} - -/** - * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and - * takes the organisation and project from the checkout it detects — so the recorded - * `org/project/_git/repo` path is refused outright and the whole repository reads as - * unavailable. Its name is the last segment, which is what this hands over. The segment comes - * from the remote URL, where a name with a space reads `my%20repo`; `az` looks that up literally - * and answers 404, so it goes out decoded. - */ -export function sourceControlRepositorySelector( - identity: - | Pick - | null - | undefined, -): string | null { - if (!identity) return null; - if (identity.provider === "azure-devops") { - const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); - const name = identity.name || segments.at(-1); - return name ? decodeUrlPathSegment(name) : null; - } - if (identity.displayName) return identity.displayName; - return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; -} - -export function canonicalRepositoryKey(key: string): string { - return key - .replace( - /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ) - .replace( - /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ); -} diff --git a/packages/source-control-azure-devops/src/client/definition.test.ts b/packages/source-control-azure-devops/src/client/definition.test.ts index c4a0e4fd526b..b739e8de831f 100644 --- a/packages/source-control-azure-devops/src/client/definition.test.ts +++ b/packages/source-control-azure-devops/src/client/definition.test.ts @@ -1,4 +1,9 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import type { ChangeRequestProjectIdentity } from "@t3tools/source-control-core/client/definition"; +import { + makeSourceControlClientRegistry, + recognizeRemoteWith, +} from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -102,3 +107,89 @@ describe("Azure DevOps change request URLs", () => { } }); }); + +describe("Azure DevOps remotes", () => { + it("recognises the SSH clone hosts as well as the web ones", () => { + for (const remote of [ + "git@ssh.dev.azure.com:v3/org/project/repo", + "ssh://git@ssh.dev.azure.com:22/v3/org/project/repo", + "git@vs-ssh.visualstudio.com:v3/org/project/repo", + "https://org@dev.azure.com/org/project/_git/repo", + ]) { + expect(recognizeRemoteWith(definition, remote)?.kind, remote).toBe("azure-devops"); + } + expect(recognizeRemoteWith(definition, "https://azure.example/o/p/_git/r")).toBeNull(); + }); +}); + +describe("Azure DevOps repository keys", () => { + const registry = makeSourceControlClientRegistry([definition]); + + it("gives a repository the same key over SSH as over HTTPS", () => { + for (const remote of [ + "git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code", + "ssh://git@ssh.dev.azure.com:22/v3/T3Tools/Platform/T3Code", + "https://T3Tools@dev.azure.com/T3Tools/Platform/_git/T3Code", + ]) { + expect(registry.repositoryKeyFromRemote(remote), remote).toBe( + "dev.azure.com/t3tools/platform/_git/t3code", + ); + } + }); + + it("keeps the organization in the host for the legacy visualstudio.com remotes", () => { + for (const remote of [ + "T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code", + "https://T3Tools.visualstudio.com/Platform/_git/T3Code", + ]) { + expect(registry.repositoryKeyFromRemote(remote), remote).toBe( + "t3tools.visualstudio.com/platform/_git/t3code", + ); + } + }); + + it("compares every spelling on dev.azure.com", () => { + for (const key of [ + "ssh.dev.azure.com/v3/org/project/repo", + "vs-ssh.visualstudio.com/v3/org/project/repo", + "org.visualstudio.com/project/_git/repo", + "org.visualstudio.com/defaultcollection/project/_git/repo", + "dev.azure.com/org/project/_git/repo", + ]) { + expect(registry.canonicalRepositoryKey(key), key).toBe("dev.azure.com/org/project/_git/repo"); + expect(registry.findByRepositoryKey(key), key).toBe(definition); + } + expect(registry.canonicalRepositoryKey("tfs.example/c/p/_git/r")).toBe( + "tfs.example/c/p/_git/r", + ); + expect(registry.findByRepositoryKey("tfs.example/c/p/_git/r")).toBeUndefined(); + }); +}); + +describe("Azure DevOps repository selector", () => { + const registry = makeSourceControlClientRegistry([definition]); + + it("names a repository by its own name, not its project path", () => { + // `az repos pr list --repository` takes a name and detects the organisation and project from + // the checkout; the recorded `org/project/_git/repo` path is refused. + expect( + registry.repositorySelector({ + provider: SourceControlProviderKind.make("azure-devops"), + displayName: "contoso/payments/_git/checkout", + owner: "contoso", + name: "checkout", + }), + ).toBe("checkout"); + }); + + it("decodes a name the remote URL percent-encodes", () => { + expect( + registry.repositorySelector({ + provider: SourceControlProviderKind.make("azure-devops"), + displayName: "contoso/payments%20(eu)/_git/checkout%20(eu)", + owner: "contoso", + name: "checkout%20(eu)", + }), + ).toBe("checkout (eu)"); + }); +}); diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index ac83fb8fc3af..e0753bc82f08 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -1,5 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import { changeRequestUrlBelow, defineSourceControlClient, @@ -14,6 +13,44 @@ const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; +/** + * Azure alone addresses one repository under two names that share no part: `ssh.dev.azure.com` and + * `v3/{org}/{project}/{repo}` over SSH, against `dev.azure.com` and `{org}/{project}/_git/{repo}` + * everywhere a person sees it. A project cloned over SSH would otherwise be a different repository + * to every comparison made against a pull request URL, which arrives in the web spelling. + */ +function repositoryKeyFromRemote(key: string): string | null { + const match = + /^(ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u.exec(key); + if (match === null) return null; + const [, host, organization, project, repository] = match; + return host === "ssh.dev.azure.com" + ? `dev.azure.com/${organization}/${project}/_git/${repository}` + : `${organization}.visualstudio.com/${project}/_git/${repository}`; +} + +/** Every spelling of an Azure DevOps Services repository, on `dev.azure.com`. */ +function canonicalRepositoryKey(key: string): string | null { + const canonical = key + .replace( + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ) + .replace( + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ); + return canonical.startsWith("dev.azure.com/") ? canonical : null; +} + +function decodeUrlPathSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + function checkoutCommandId(args: string): string | null { const parts = args.trim().split(/\s+/).filter(Boolean); for (const [index, part] of parts.entries()) { @@ -23,8 +60,10 @@ function checkoutCommandId(args: string): string | null { return parts.find((part) => !part.startsWith("-")) ?? null; } +const KIND = SourceControlProviderKind.make("azure-devops"); + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("azure-devops"), + kind: KIND, label: "Azure DevOps", pickerLabel: "Azure DevOps", icon: "azure-devops", @@ -39,9 +78,8 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { if (webUrl) return changeRequestUrlBelow(webUrl, `pullrequest/${number}`); - const [canonicalHost = host, ...path] = canonicalRepositoryKey( - `${host}/${repository}`.toLowerCase(), - ).split("/"); + const key = `${host}/${repository}`.toLowerCase(); + const [canonicalHost = host, ...path] = (canonicalRepositoryKey(key) ?? key).split("/"); return `${remoteWebOrigin(canonicalHost, remoteUrl)}/${path.join("/")}/pullrequest/${number}`; }, // Servers on different ports are different servers, so a port stays in the link. @@ -69,9 +107,37 @@ export const definition = defineSourceControlClient({ }, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: () => null, - isChangeRequestInRepository: (identity, link) => - isChangeRequestAuthorityOfProject(identity, link) && - canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === - canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()), + // `ssh.dev.azure.com` is the default SSH clone host (git@ssh.dev.azure.com:v3/org/project/repo), + // so any `*.dev.azure.com` subdomain is Azure. Legacy hosts stay under `.visualstudio.com`, + // including `vs-ssh.visualstudio.com`. + recognizeRemote: ({ host, hostname }) => + hostname === "dev.azure.com" || + hostname.endsWith(".dev.azure.com") || + hostname.endsWith(".visualstudio.com") + ? { kind: KIND, name: "Azure DevOps", baseUrl: `https://${host}` } + : null, + /** + * `az repos pr list --repository` takes a repository name, and takes the organisation and + * project from the checkout it detects, so the recorded `org/project/_git/repo` path is refused + * outright and the whole repository reads as unavailable. The segment comes from the remote URL, + * where a name with a space reads `my%20repo`; `az` looks that up literally and answers 404, so + * it goes out decoded. + */ + repositorySelector: (identity) => { + const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); + const name = identity.name || segments.at(-1); + return name ? decodeUrlPathSegment(name) : null; + }, + repositoryKeyFromRemote, + canonicalRepositoryKey, + isChangeRequestInRepository: (identity, link) => { + if (!isChangeRequestAuthorityOfProject(identity, link)) return false; + const projectKey = identity.canonicalKey.toLowerCase(); + const linkKey = `${link.host}/${link.repository}`.toLowerCase(); + return ( + (canonicalRepositoryKey(projectKey) ?? projectKey) === + (canonicalRepositoryKey(linkKey) ?? linkKey) + ); + }, canReadChangeRequestOnHost: () => false, }); diff --git a/packages/source-control-bitbucket/src/client/definition.test.ts b/packages/source-control-bitbucket/src/client/definition.test.ts index 5c53884c3376..c28e0dfbac38 100644 --- a/packages/source-control-bitbucket/src/client/definition.test.ts +++ b/packages/source-control-bitbucket/src/client/definition.test.ts @@ -1,3 +1,4 @@ +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -36,3 +37,19 @@ describe("Bitbucket change request URLs", () => { ).toBe("https://bitbucket.org/w/repo/pull-requests/5"); }); }); + +describe("Bitbucket remotes", () => { + const recognize = (url: string) => recognizeRemoteWith(definition, url); + + it("recognises bitbucket.org and hosts with a bitbucket label", () => { + expect(recognize("git@bitbucket.org:workspace/repo.git")).toEqual({ + kind: "bitbucket", + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }); + expect(recognize("https://bitbucket.example.com/workspace/repo.git")?.name).toBe( + "Bitbucket Self-Hosted", + ); + expect(recognize("https://notbitbucket.example.com/workspace/repo.git")).toBeNull(); + }); +}); diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 2d423fbb2279..3ad2e97aefd5 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -11,6 +11,7 @@ import { isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; @@ -97,9 +98,17 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: () => null, isChangeRequestReference: () => false, checkoutChangeRequestHost: () => null, + recognizeRemote: ({ host, hostname }) => + hostname === "bitbucket.org" || hostname.split(".").includes("bitbucket") + ? { + kind: KIND, + name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", + baseUrl: `https://${host}`, + } + : null, isChangeRequestInRepository: (identity, link) => - isChangeRequestInProjectRepository(KIND, identity, link), + isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => - isChangeRequestOnProjectHost(KIND, identity, link), + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), settings, }); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 2da9b4630b4b..07c7064b4498 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -17,10 +17,9 @@ import { } from "@t3tools/contracts"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { sanitizeBranchFragment } from "@t3tools/shared/git"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; +import { definition } from "../client/definition.ts"; import { BitbucketPullRequestListSchema, @@ -791,7 +790,7 @@ export const make = Effect.gen(function* () { ); for (const remote of remotes.remotes) { - if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; + if (recognizeRemoteWith(definition, remote.url) === null) continue; const parsed = parseBitbucketRemoteUrl(remote.url); if (parsed) return parsed; } diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index 619b138ded51..ac7b947f1714 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -147,6 +147,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("bitbucket"), capabilities: CAPABILITIES, + readsPublicHostWithoutCheckout: true, getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 69a4fa402627..9a13dd20a4ee 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -1,14 +1,15 @@ import { type PullRequestAction, - pullRequestHostOf, type PullRequestReviewVerdict, type RepositoryIdentity, SourceControlProviderKind, + type SourceControlProviderInfo, } from "@t3tools/contracts"; +import { normalizeGitRemoteUrl, parseGitRemoteRepository } from "@t3tools/shared/git"; import * as Schema from "effect/Schema"; /** - * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce + * The two strings are what `changeRequestHost` and the project's `repositoryIdentity` produce * from a git remote — lower case, no port, the full path below the host — because links are * matched against those. `authority` keeps the port for hosts whose identity includes it. */ @@ -43,6 +44,29 @@ export type ChangeRequestProjectIdentity = Pick< "canonicalKey" | "locator" | "webUrl" | "displayName" | "owner" | "name" >; +export type ChangeRequestHostIdentity = + | { + readonly canonicalKey?: string | undefined; + readonly locator?: { readonly remoteUrl: string } | undefined; + } + | null + | undefined; + +export type RepositorySelectorIdentity = Pick< + RepositoryIdentity, + "provider" | "displayName" | "owner" | "name" +>; + +/** A git remote's host, as `recognizeRemote` reads it. */ +export interface GitRemoteHost { + /** Trimmed. */ + readonly remoteUrl: string; + /** Lower case. Keeps the port of an HTTP remote, never an SSH one. */ + readonly host: string; + /** `host` without its port. */ + readonly hostname: string; +} + export interface ChangeRequestCheckoutInput { readonly number: number; readonly headBranch: string; @@ -91,6 +115,32 @@ export interface SourceControlClientDefinition { link: ChangeRequestLink, ) => boolean; readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; + /** Null when this host does not claim the remote. Asked in the registry's recognition order. */ + readonly recognizeRemote: (remote: GitRemoteHost) => SourceControlProviderInfo | null; + /** The host change requests in the project are listed and keyed under. */ + readonly changeRequestHost?: (identity: ChangeRequestHostIdentity) => string; + /** + * What the host's tools take to select the project's repository. Absent means the repository + * path, `owner/name`. + */ + readonly repositorySelector?: (identity: RepositorySelectorIdentity) => string | null; + /** + * For a host that spells one repository differently over SSH and on the web: the web spelling + * of the `host/path` key `normalizeGitRemoteUrl` made from a remote that names a host. Null when + * the key is not this host's. Persisted repository keys are built with it, so its output must + * not change. + */ + readonly repositoryKeyFromRemote?: (key: string) => string | null; + /** + * For a host with more than one spelling of a repository key: the one every spelling compares + * equal under. Null when the key is not this host's. + */ + readonly canonicalRepositoryKey?: (key: string) => string | null; + /** + * An SSH remote names the SSH server only, so the web host, and its port, come from resolving + * the provider rather than from the remote. + */ + readonly sshRemoteHidesWebHost?: boolean; readonly settings?: SourceControlHostSettingsSchema; } @@ -160,14 +210,23 @@ export function isChangeRequestAuthorityOfProject( return true; } +/** The host of the project's repository key, or `kind` when the key has none. */ +export function projectChangeRequestHost( + identity: ChangeRequestHostIdentity, + kind: Kind, +): string | Kind { + const host = identity?.canonicalKey?.split("/")[0]?.trim(); + return host === undefined || host.length === 0 ? kind : host.toLowerCase(); +} + +/** `projectHost` is the project's `changeRequestHost`. */ export function isChangeRequestOnProjectHost( - kind: SourceControlProviderKind, + projectHost: string, identity: ChangeRequestProjectIdentity, link: ChangeRequestLink, ): boolean { if (!isChangeRequestAuthorityOfProject(identity, link)) return false; - const host = pullRequestHostOf(identity, kind); - return host === link.host.toLowerCase() || host === link.authority; + return projectHost === link.host.toLowerCase() || projectHost === link.authority; } function projectRepositoryPath(identity: ChangeRequestProjectIdentity): string | null { @@ -178,7 +237,7 @@ function projectRepositoryPath(identity: ChangeRequestProjectIdentity): string | } export function isChangeRequestInProjectRepository( - kind: SourceControlProviderKind, + projectHost: string, identity: ChangeRequestProjectIdentity, link: ChangeRequestLink, ): boolean { @@ -186,10 +245,64 @@ export function isChangeRequestInProjectRepository( return ( repository !== null && repository.toLowerCase() === link.repository.toLowerCase() && - isChangeRequestOnProjectHost(kind, identity, link) + isChangeRequestOnProjectHost(projectHost, identity, link) ); } +const SCP_REMOTE = /^[a-zA-Z0-9._-]+@([^:/]+):/; + +/** Null when the remote names no host, such as a local path. */ +export function parseGitRemoteHost(remoteUrl: string): GitRemoteHost | null { + const trimmed = remoteUrl.trim(); + if (trimmed.length === 0) return null; + let host: string; + const scpHost = SCP_REMOTE.exec(trimmed)?.[1]; + if (scpHost) { + host = scpHost.toLowerCase(); + } else { + try { + const url = new URL(trimmed); + host = (url.protocol === "ssh:" ? url.hostname : url.host).toLowerCase(); + } catch { + return null; + } + } + if (host.length === 0) return null; + let hostname: string; + try { + hostname = new URL(`https://${host}`).hostname.toLowerCase(); + } catch { + hostname = host.replace(/:\d+$/u, "").toLowerCase(); + } + return { remoteUrl: trimmed, host, hostname }; +} + +/** What a remote on a host no definition claims reads as. */ +export function unknownRemoteProvider(remote: GitRemoteHost): SourceControlProviderInfo { + return { + kind: SourceControlProviderKind.make("unknown"), + name: remote.host, + baseUrl: `https://${remote.host}`, + }; +} + +/** What `definition` alone makes of a remote, for a host's own code that asks only about itself. */ +export function recognizeRemoteWith( + definition: Pick, + remoteUrl: string, +): SourceControlProviderInfo | null { + const remote = parseGitRemoteHost(remoteUrl); + return remote === null ? null : definition.recognizeRemote(remote); +} + +function defaultRepositorySelector( + identity: RepositorySelectorIdentity | null | undefined, +): string | null { + if (!identity) return null; + if (identity.displayName) return identity.displayName; + return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; +} + export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { kind: SourceControlProviderKind.make("unknown"), label: "source control", @@ -213,10 +326,15 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { checkoutCommandArgument: () => null, isChangeRequestReference: () => false, checkoutChangeRequestHost: () => null, + recognizeRemote: () => null, isChangeRequestInRepository: (identity, link) => - isChangeRequestInProjectRepository(SourceControlProviderKind.make("unknown"), identity, link), + isChangeRequestInProjectRepository( + projectChangeRequestHost(identity, "unknown"), + identity, + link, + ), canReadChangeRequestOnHost: (identity, link) => - isChangeRequestOnProjectHost(SourceControlProviderKind.make("unknown"), identity, link), + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, "unknown"), identity, link), }; export interface ParsedChangeRequest { @@ -235,6 +353,23 @@ export interface SourceControlClientRegistry { readonly hostLabelForChangeRequestUrl: (url: string) => string; readonly isSameChangeRequest: (left: string, right: string) => boolean; readonly changeRequestUrlFromReferenceAutolink: (url: string) => string | null; + /** + * The host a remote is on, from the first definition in recognition order that claims it, or an + * unknown host. Null when the remote names no host. + */ + readonly recognizeRemote: (remoteUrl: string) => SourceControlProviderInfo | null; + /** `normalizeGitRemoteUrl`, in the spelling the remote's host uses on the web. */ + readonly repositoryKeyFromRemote: (remoteUrl: string) => string; + readonly canonicalRepositoryKey: (key: string) => string; + /** The host that has more than one spelling of `key`, if any does. */ + readonly findByRepositoryKey: (key: string) => SourceControlClientDefinition | undefined; + readonly repositorySelector: ( + identity: RepositorySelectorIdentity | null | undefined, + ) => string | null; + readonly changeRequestHost: ( + identity: ChangeRequestHostIdentity, + kind: Kind, + ) => string | Kind; } function httpUrl(value: string): URL | null { @@ -246,15 +381,30 @@ function httpUrl(value: string): URL | null { } } +function inOrder( + definitions: ReadonlyArray, + firstKinds: ReadonlyArray | undefined, +): ReadonlyArray { + const first = (firstKinds ?? []).flatMap( + (kind) => definitions.find((definition) => definition.kind === kind) ?? [], + ); + return [...first, ...definitions.filter((definition) => !first.includes(definition))]; +} + /** * `definitions` is display order, and its first entry is what `get` returns for a repository * that has not reported its host yet. A change request URL goes to the first definition that * reads it, trying `claimChangeRequestUrlsFirst` before the rest, so a host that claims a route - * only on its own hostnames can precede one that claims the route everywhere. + * only on its own hostnames can precede one that claims the route everywhere. A remote likewise + * goes to the first that recognises it, in `recognizeRemotesIn` order and then the rest, because + * one hostname can carry several hosts' labels. */ export function makeSourceControlClientRegistry( definitions: ReadonlyArray, - options?: { readonly claimChangeRequestUrlsFirst?: ReadonlyArray }, + options?: { + readonly claimChangeRequestUrlsFirst?: ReadonlyArray; + readonly recognizeRemotesIn?: ReadonlyArray; + }, ): SourceControlClientRegistry { const byKind = new Map(); for (const definition of definitions) { @@ -263,11 +413,15 @@ export function makeSourceControlClientRegistry( } byKind.set(definition.kind, definition); } - const first = new Set(options?.claimChangeRequestUrlsFirst); - const claimOrder = [ - ...definitions.filter((definition) => first.has(definition.kind)), - ...definitions.filter((definition) => !first.has(definition.kind)), - ]; + const claimOrder = inOrder(definitions, options?.claimChangeRequestUrlsFirst); + const recognitionOrder = inOrder(definitions, options?.recognizeRemotesIn); + const repositoryKeyOwner = (key: string) => { + for (const definition of definitions) { + const canonical = definition.canonicalRepositoryKey?.(key); + if (canonical != null) return { definition, canonical }; + } + return undefined; + }; const parseChangeRequestUrl = (value: string): ParsedChangeRequest | null => { const url = httpUrl(value); if (url === null) return null; @@ -311,6 +465,35 @@ export function makeSourceControlClientRegistry( } return null; }, + recognizeRemote: (remoteUrl) => { + const remote = parseGitRemoteHost(remoteUrl); + if (remote === null) return null; + for (const definition of recognitionOrder) { + const provider = definition.recognizeRemote(remote); + if (provider !== null) return provider; + } + return unknownRemoteProvider(remote); + }, + repositoryKeyFromRemote: (remoteUrl) => { + const key = normalizeGitRemoteUrl(remoteUrl); + if (parseGitRemoteRepository(remoteUrl) === null) return key; + for (const definition of definitions) { + const spelled = definition.repositoryKeyFromRemote?.(key); + if (spelled != null) return spelled; + } + return key; + }, + canonicalRepositoryKey: (key) => repositoryKeyOwner(key)?.canonical ?? key, + findByRepositoryKey: (key) => repositoryKeyOwner(key)?.definition, + repositorySelector: (identity) => + identity?.provider === undefined + ? defaultRepositorySelector(identity) + : (byKind.get(identity.provider)?.repositorySelector ?? defaultRepositorySelector)( + identity, + ), + changeRequestHost: (identity, kind) => + (kind === undefined ? undefined : byKind.get(kind)?.changeRequestHost?.(identity)) ?? + projectChangeRequestHost(identity, kind), }; } diff --git a/packages/source-control-core/src/client/threadPullRequests.ts b/packages/source-control-core/src/client/threadPullRequests.ts index a16fbae8572f..3655f74e6088 100644 --- a/packages/source-control-core/src/client/threadPullRequests.ts +++ b/packages/source-control-core/src/client/threadPullRequests.ts @@ -4,7 +4,6 @@ import type { ThreadPullRequestKey, ThreadPullRequestLink, } from "@t3tools/contracts"; -import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { SourceControlClientRegistry } from "./definition.ts"; @@ -63,7 +62,10 @@ export type ThreadPullRequestBadge = { * binds these once to its own registry. */ export function makeThreadPullRequests( - registry: Pick, + registry: Pick< + SourceControlClientRegistry, + "parseChangeRequestUrl" | "canonicalRepositoryKey" | "findByRepositoryKey" + >, ) { const parse = (url: string) => registry.parseChangeRequestUrl(url)?.link ?? null; @@ -74,7 +76,7 @@ export function makeThreadPullRequests( (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number ? parsed.authority : undefined); - const canonical = canonicalRepositoryKey( + const canonical = registry.canonicalRepositoryKey( `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, ); const separator = canonical.indexOf("/"); @@ -91,8 +93,10 @@ export function makeThreadPullRequests( ): ThreadPullRequestKey { const parsed = parse(linked.url); if (parsed !== null && parsed.number === linked.number) { - const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); - if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { + if ( + parsed.authority !== undefined || + registry.findByRepositoryKey(`${parsed.host}/${parsed.repository}`) !== undefined + ) { return normalizeThreadPullRequestKey(parsed); } } diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 32a06d2b314e..18fb90a3b3bf 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -296,6 +296,11 @@ export interface PullRequestProviderApi { * organization from the checkout, so only a checkout whose key matches the reference serves it. */ readonly repositoryKey?: (input: { readonly canonicalKey: string }) => string; + /** + * Names the repository in every request and authenticates by host, so a change request on the + * definition's `publicHost` can be read from a project with no checkout of its repository. + */ + readonly readsPublicHostWithoutCheckout?: boolean; readonly getViewer: (input: { readonly cwd: string; diff --git a/packages/source-control-forgejo/src/client/definition.test.ts b/packages/source-control-forgejo/src/client/definition.test.ts index 74d4cddbcd4d..f2d31320212c 100644 --- a/packages/source-control-forgejo/src/client/definition.test.ts +++ b/packages/source-control-forgejo/src/client/definition.test.ts @@ -1,4 +1,5 @@ import type { ChangeRequestProjectIdentity } from "@t3tools/source-control-core/client/definition"; +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -102,3 +103,29 @@ describe("Forgejo change request URLs", () => { } }); }); + +describe("Forgejo remotes", () => { + it("recognises Codeberg and Forgejo or Gitea hosts, keeping an HTTP remote's origin", () => { + for (const host of ["codeberg.org", "forgejo.example.test", "gitea.example.test"]) { + expect(recognizeRemoteWith(definition, `http://${host}:3000/team/repo.git`)).toEqual({ + kind: "forgejo", + name: "Forgejo", + baseUrl: `http://${host}:3000`, + }); + } + expect( + recognizeRemoteWith(definition, "ssh://git@forgejo.example.test:2222/team/repo.git")?.baseUrl, + ).toBe("https://forgejo.example.test"); + expect(recognizeRemoteWith(definition, "https://git.example.test/team/repo")).toBeNull(); + }); +}); + +describe("Forgejo change request host", () => { + it("keeps an HTTP remote's port, because a server's identity includes it", () => { + const project = identity("http://forge.example:3000/git/team/repo.git"); + expect(definition.changeRequestHost?.(project)).toBe("forge.example:3000"); + expect( + definition.changeRequestHost?.(identity("ssh://git@forge.example:2222/git/team/repo.git")), + ).toBe("forge.example"); + }); +}); diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index 9f8c2aa3573e..1411a42c1e23 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -1,12 +1,14 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { changeRequestUrlBelow, + type ChangeRequestHostIdentity, type ChangeRequestProjectIdentity, defineSourceControlClient, isChangeRequestAuthorityOfProject, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + projectChangeRequestHost, remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; @@ -33,6 +35,19 @@ function resolvedWebUrl(identity: ChangeRequestProjectIdentity): URL | null { } } +/** A server's identity includes its port, which only an HTTP remote names. */ +function changeRequestHost(identity: ChangeRequestHostIdentity): string { + try { + const remote = new URL(identity?.locator?.remoteUrl ?? ""); + if (remote.protocol === "http:" || remote.protocol === "https:") { + return remote.host.toLowerCase(); + } + } catch { + // SSH remotes do not name the web server. + } + return projectChangeRequestHost(identity, KIND); +} + export const definition = defineSourceControlClient({ kind: KIND, label: "Forgejo", @@ -40,8 +55,8 @@ export const definition = defineSourceControlClient({ icon: "forgejo", changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "owner/repo", - // Forgejo and Gitea are self-hosted, so no hostname names them. - publicHost: null, + // Codeberg is the public Forgejo; any other server is self-hosted. + publicHost: "codeberg.org", publishDescription: "Your signed-in server", publishHost: (signedInHost) => signedInHost ?? "your server", newRepositoryOwner: (account) => (account ? { owner: account } : null), @@ -71,10 +86,23 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: () => null, + recognizeRemote: ({ remoteUrl, host, hostname }) => + hostname === "codeberg.org" || + hostname.split(".").includes("forgejo") || + hostname.split(".").includes("gitea") + ? { + kind: KIND, + name: "Forgejo", + baseUrl: /^https?:/iu.test(remoteUrl) ? new URL(remoteUrl).origin : `https://${host}`, + } + : null, + changeRequestHost, + sshRemoteHidesWebHost: true, isChangeRequestInRepository: (identity, link) => { if (!isChangeRequestAuthorityOfProject(identity, link)) return false; const web = resolvedWebUrl(identity); - if (!web) return isChangeRequestInProjectRepository(KIND, identity, link); + if (!web) + return isChangeRequestInProjectRepository(changeRequestHost(identity), identity, link); return ( web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase() @@ -83,7 +111,7 @@ export const definition = defineSourceControlClient({ // A server mounted below a path serves only repositories under that mount. canReadChangeRequestOnHost: (identity, link) => { const web = resolvedWebUrl(identity); - if (!web) return isChangeRequestOnProjectHost(KIND, identity, link); + if (!web) return isChangeRequestOnProjectHost(changeRequestHost(identity), identity, link); const mount = web.pathname .replace(/^\/+|\/+$/g, "") .split("/") diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index b7616c276c3c..2ba5b43a4dd5 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -259,6 +259,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("forgejo"), capabilities: CAPABILITIES, + readsPublicHostWithoutCheckout: true, getViewer, listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( function* (input) { diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index c3e8146f5f0f..5b4d92418e0c 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -597,7 +597,7 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the canonicalKey: "forge.test/team/repo", locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl }, rootPath: "/repo", - ...(provider === undefined ? {} : { provider }), + ...(provider === undefined ? {} : { provider: SourceControlProviderKind.make(provider) }), }); const served = (baseUrl: string) => () => Effect.succeed({ diff --git a/packages/source-control-gitcafe/src/client/definition.test.ts b/packages/source-control-gitcafe/src/client/definition.test.ts index 4bc0c017aaa1..9c4914171500 100644 --- a/packages/source-control-gitcafe/src/client/definition.test.ts +++ b/packages/source-control-gitcafe/src/client/definition.test.ts @@ -1,3 +1,4 @@ +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -47,3 +48,17 @@ describe("GitCafe change request URLs", () => { ).toBe("https://git.cafe/Team/Repo/pulls/8"); }); }); + +describe("GitCafe remotes", () => { + it("recognises its own hosts only, without a port", () => { + expect(recognizeRemoteWith(definition, "https://git.cafe:8443/a/b")).toEqual({ + kind: "gitcafe", + name: "GitCafe", + baseUrl: "https://git.cafe", + }); + expect(recognizeRemoteWith(definition, "git@staging.git.cafe:team/repo.git")?.kind).toBe( + "gitcafe", + ); + expect(recognizeRemoteWith(definition, "https://cafe.example/a/b")).toBeNull(); + }); +}); diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index ac7736d541b7..f0ea69bdbd2a 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -12,6 +12,7 @@ import { isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; /** GitCafe has no self-hosted installs: production and staging are its only hosts. */ @@ -74,9 +75,13 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: () => null, isChangeRequestReference: () => false, checkoutChangeRequestHost: () => null, + recognizeRemote: ({ hostname }) => + GITCAFE_HOSTS.has(hostname) + ? { kind: KIND, name: "GitCafe", baseUrl: `https://${hostname}` } + : null, isChangeRequestInRepository: (identity, link) => - isChangeRequestInProjectRepository(KIND, identity, link), + isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => - isChangeRequestOnProjectHost(KIND, identity, link), + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), settings, }); diff --git a/packages/source-control-github/src/client/definition.test.ts b/packages/source-control-github/src/client/definition.test.ts index 5e2153bbb056..c929be7167aa 100644 --- a/packages/source-control-github/src/client/definition.test.ts +++ b/packages/source-control-github/src/client/definition.test.ts @@ -1,3 +1,4 @@ +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -94,3 +95,29 @@ describe("GitHub change request URLs", () => { expect(candidate("https://code.example.test/acme/web/issues/42")).toBeNull(); }); }); + +describe("GitHub remotes", () => { + const recognize = (url: string) => recognizeRemoteWith(definition, url); + + it("recognises github.com, ghe.com subdomains and hosts with a github label", () => { + expect(recognize("deploy@github.com:owner/repo.git")).toEqual({ + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }); + expect(recognize("https://acme.ghe.com/owner/repo.git")).toEqual({ + kind: "github", + name: "GitHub Self-Hosted", + baseUrl: "https://acme.ghe.com", + }); + expect(recognize("git@acme.ghe.com:owner/repo.git")?.kind).toBe("github"); + expect(recognize("https://github.example.com:8443/owner/repo.git")?.baseUrl).toBe( + "https://github.example.com:8443", + ); + }); + + it("does not claim ghe.com itself or a label that only contains github", () => { + expect(recognize("https://ghe.com/owner/repo.git")).toBeNull(); + expect(recognize("https://notgithub.example.com/owner/repo.git")).toBeNull(); + }); +}); diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index f9db4b87a3a0..c022ed824b41 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -2,7 +2,6 @@ import { GitHubHost, makeProviderSettingsSchema, secretSetting, - pullRequestHostOf, SourceControlProviderKind, TrimmedNonEmptyString, TrimmedString, @@ -15,6 +14,7 @@ import { isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + projectChangeRequestHost, remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; @@ -118,10 +118,21 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - checkoutChangeRequestHost: (identity) => pullRequestHostOf(identity, KIND), + checkoutChangeRequestHost: (identity) => projectChangeRequestHost(identity, KIND), + // `*.ghe.com` is GitHub Enterprise Cloud with data residency, one subdomain per enterprise. + recognizeRemote: ({ host, hostname }) => + hostname === "github.com" || + hostname.endsWith(".ghe.com") || + hostname.split(".").includes("github") + ? { + kind: KIND, + name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", + baseUrl: `https://${host}`, + } + : null, isChangeRequestInRepository: (identity, link) => - isChangeRequestInProjectRepository(KIND, identity, link), + isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => - isChangeRequestOnProjectHost(KIND, identity, link), + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), settings, }); diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index ab770dac4e03..f57bf3ee2246 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -243,6 +243,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("github"), capabilities: CAPABILITIES, + readsPublicHostWithoutCheckout: true, mergeMessageRewrite: removeAgentCredits, getRoutingIdentity: (input) => cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))), diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index d9e2e9743e16..ddf57a42c33c 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -1,8 +1,7 @@ import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; +import { parseGitRemoteHost } from "@t3tools/source-control-core/client/definition"; +import { definition } from "../client/definition.ts"; export interface GitHubRepositoryLocator { readonly host: string; @@ -163,13 +162,13 @@ export function parsePullRequestReference( * #6198). */ export function gitHubApiHostForRemote(remoteUrl: string): string | null { - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); - if (provider === null) return null; - const host = new URL(provider.baseUrl).host.toLowerCase(); + const remote = parseGitRemoteHost(remoteUrl); + if (remote === null) return null; + const host = new URL(`https://${remote.host}`).host.toLowerCase(); if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { return host.includes("github") ? "github.com" : null; } - return provider.kind === "github" ? host : null; + return definition.recognizeRemote(remote) === null ? null : host; } function apiHostForHint(host: string): string { diff --git a/packages/source-control-gitlab/src/client/definition.test.ts b/packages/source-control-gitlab/src/client/definition.test.ts index 4184084e3864..02514ee373db 100644 --- a/packages/source-control-gitlab/src/client/definition.test.ts +++ b/packages/source-control-gitlab/src/client/definition.test.ts @@ -1,3 +1,4 @@ +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -48,3 +49,27 @@ describe("GitLab change request URLs", () => { ).toBe("https://gitlab.com/group/sub/project/-/merge_requests/4"); }); }); + +describe("GitLab remotes", () => { + const recognize = (url: string) => recognizeRemoteWith(definition, url); + + it("keeps an HTTP port in the base URL and drops an SSH one", () => { + expect(recognize("https://gitlab.com:8443/group/repo.git")).toEqual({ + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com:8443", + }); + expect(recognize("ssh://git@gitlab.example.test:24/group/repo.git")).toEqual({ + kind: "gitlab", + name: "GitLab Self-Hosted", + baseUrl: "https://gitlab.example.test", + }); + expect(recognize("gitlab@gitlab.example.com:group/project.git")?.baseUrl).toBe( + "https://gitlab.example.com", + ); + }); + + it("matches complete DNS labels only", () => { + expect(recognize("https://notgitlab.example.com/group/repo.git")).toBeNull(); + }); +}); diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index a3f4a153a1a0..e944270a94e8 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -5,6 +5,7 @@ import { isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; @@ -51,8 +52,16 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: () => null, + recognizeRemote: ({ host, hostname }) => + hostname === "gitlab.com" || hostname.split(".").includes("gitlab") + ? { + kind: KIND, + name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", + baseUrl: `https://${host}`, + } + : null, isChangeRequestInRepository: (identity, link) => - isChangeRequestInProjectRepository(KIND, identity, link), + isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => - isChangeRequestOnProjectHost(KIND, identity, link), + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), }); diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index 93defc897d79..2337b8b5fd74 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -1,8 +1,5 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import { - detectSourceControlProviderFromGitRemoteUrl, - normalizeGitRemoteUrl, -} from "@t3tools/shared/git"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { refineUnknownRemoteProvider } from "@t3tools/source-control-core/server/discovery"; import { processOutput } from "@t3tools/source-control-testing/TestSourceControlHost"; import { assert, it } from "@effect/vitest"; @@ -128,7 +125,10 @@ it.effect.each([ canonicalKey: normalizeGitRemoteUrl(remoteUrl), locator: { source: "git-remote" as const, remoteName: "upstream", remoteUrl }, rootPath: "/repo", - provider: detectSourceControlProviderFromGitRemoteUrl(remoteUrl)?.kind ?? "unknown", + // Only a remote no host recognised is left for glab to refine. + provider: SourceControlProviderKind.make( + expectedProvider === "github" ? "github" : "unknown", + ), }; let authCalls = 0; const refined = yield* ( @@ -177,7 +177,10 @@ it.effect.each([ "https://code.example.test/team/repo/-/merge_requests/42", ); } - assert.deepStrictEqual(refined, { ...identity, provider: expectedProvider }); + assert.deepStrictEqual(refined, { + ...identity, + provider: SourceControlProviderKind.make(expectedProvider), + }); if (expectedProvider === "github") assert.strictEqual(authCalls, 0); }), ); diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index 38c404e6523f..d8404c6dbb7b 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -3,7 +3,10 @@ import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { + parseGitRemoteHost, + unknownRemoteProvider as unknownHostProvider, +} from "@t3tools/source-control-core/client/definition"; import * as GitLabCli from "./GitLabCli.ts"; import * as GitLabChangeRequestTemplate from "./gitLabChangeRequestTemplate.ts"; @@ -105,13 +108,12 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn } as const; } +/** The caller refines only an identity no host recognised, so its remote is on an unknown host. */ function unknownRemoteProvider(remoteUrl: string) { - // The shared detector does not recognize userless SCP remotes. + // Remote host parsing does not recognize userless SCP remotes. const scpHost = /^([^@:/\\\s]{2,}):\/?[^/\s]+(?:\/[^/\s]+)+$/u.exec(remoteUrl.trim())?.[1]; - const provider = detectSourceControlProviderFromRemoteUrl( - scpHost ? `ssh://${scpHost}` : remoteUrl, - ); - return provider?.kind === "unknown" ? provider : null; + const remote = parseGitRemoteHost(scpHost ? `ssh://${scpHost}` : remoteUrl); + return remote === null ? null : unknownHostProvider(remote); } // A glab login confirms a self-hosted GitLab whose hostname does not identify its provider. From 7368bd307bbfdebeddfacdc0049339664d8c27dc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:34 -0700 Subject: [PATCH 04/65] refactor(source-control): drivers build their own services and own host media (#23994) Co-authored-by: Claude Opus 5.5 --- .../pull-requests/PullRequestMedia.tsx | 15 +- .../pull-requests/pullRequestMediaSource.ts | 8 +- apps/mobile/src/lib/videoPreviewSource.ts | 19 ++- apps/server/src/assets/AssetAccess.test.ts | 145 +++++++----------- apps/server/src/assets/AssetAccess.ts | 29 ++-- apps/server/src/assets/GitHubMediaFetch.ts | 138 ----------------- .../src/assets/sourceControlMediaResponse.ts | 80 ++++++++++ apps/server/src/http.ts | 13 +- .../PullRequestProviderRegistry.ts | 8 +- apps/server/src/server.ts | 5 +- .../SourceControlDiscovery.test.ts | 4 + .../SourceControlProviderRegistry.test.ts | 95 ++++++------ .../SourceControlProviderRegistry.ts | 16 +- .../SourceControlRepositoryService.test.ts | 45 ++++++ .../SourceControlRepositoryService.ts | 11 +- .../sourceControl/builtInDrivers.testkit.ts | 40 +++++ .../src/sourceControl/builtInDrivers.ts | 54 +++---- apps/server/src/ws.ts | 3 +- apps/web/src/components/ChatMarkdown.tsx | 34 ++-- .../src/components/chat/ChatMarkdownMedia.tsx | 4 +- .../pullRequest/PullRequestMarkdown.tsx | 27 ++-- knip.jsonc | 3 +- .../client-runtime/src/pullRequestMarkdown.ts | 5 +- packages/contracts/src/assets.ts | 19 ++- packages/shared/package.json | 4 - .../src/server/driver.ts | 23 ++- .../src/server/BitbucketApi.ts | 4 + .../server/BitbucketSourceControlProvider.ts | 11 +- .../src/server/driver.ts | 27 +++- .../src/client/definition.ts | 30 ++++ .../src/server/SourceControlProvider.ts | 13 ++ .../source-control-core/src/server/driver.ts | 63 +++++++- .../src/server/driver.ts | 21 ++- .../src/server/driver.ts | 23 ++- .../scripts/measure-pr-preview.ts | 65 +++++--- .../src/client/definition.test.ts | 38 +++++ .../src/client/definition.ts | 3 + .../src/client/gitHubMedia.ts} | 63 ++++---- .../src/server/GitHubRest.ts | 7 +- .../src/server/driver.ts | 29 +++- .../src/server/gitHubMediaFetch.test.ts | 104 +++++++++++++ .../src/server/gitHubMediaFetch.ts | 68 ++++++++ packages/source-control-github/tsconfig.json | 2 +- .../src/server/driver.ts | 23 ++- 44 files changed, 947 insertions(+), 494 deletions(-) delete mode 100644 apps/server/src/assets/GitHubMediaFetch.ts create mode 100644 apps/server/src/assets/sourceControlMediaResponse.ts create mode 100644 apps/server/src/sourceControl/builtInDrivers.testkit.ts rename {apps/server => packages/source-control-github}/scripts/measure-pr-preview.ts (69%) rename packages/{shared/src/githubMedia.ts => source-control-github/src/client/gitHubMedia.ts} (51%) create mode 100644 packages/source-control-github/src/server/gitHubMediaFetch.test.ts create mode 100644 packages/source-control-github/src/server/gitHubMediaFetch.ts diff --git a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx index 8c176059ec3f..8f97b9c22899 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx @@ -1,8 +1,12 @@ import { createContext, useContext, useMemo, useState } from "react"; import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource"; import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + SOURCE_CONTROL_MEDIA_ASSET, + type AssetResource, + type EnvironmentId, +} from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; import { Pressable, View } from "react-native"; import { Image } from "expo-image"; @@ -25,9 +29,12 @@ export const PullRequestMediaContext = createContext<{ export function usePullRequestMediaUrl(url: string) { const context = useContext(PullRequestMediaContext); - const fetchUrl = githubMediaFetchUrl(url); + const fetchUrl = sourceControlClients.mediaUrl(url)?.fetchUrl; const resource = useMemo( - () => (context && fetchUrl ? { _tag: "github-media", cwd: context.cwd, url: fetchUrl } : null), + () => + context && fetchUrl + ? { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: context.cwd, url: fetchUrl } + : null, [context, fetchUrl], ); const asset = useAssetUrlState(context?.environmentId ?? null, resource); diff --git a/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts b/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts index e45cd548bcc5..075eccfc38d9 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts @@ -1,5 +1,5 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { SOURCE_CONTROL_MEDIA_ASSET, type EnvironmentId } from "@t3tools/contracts"; import type { FilePreviewItem } from "../../components/FilePreviewModal.types"; export interface PullRequestMediaLocation { @@ -9,11 +9,11 @@ export interface PullRequestMediaLocation { /** Keep the original resource identity so previewers can resolve and renew its capability. */ export function pullRequestMediaSource(url: string, location: PullRequestMediaLocation | null) { - const fetchUrl = githubMediaFetchUrl(url); + const fetchUrl = sourceControlClients.mediaUrl(url)?.fetchUrl; return location && fetchUrl ? { environmentId: location.environmentId, - resource: { _tag: "github-media" as const, cwd: location.cwd, url: fetchUrl }, + resource: { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: location.cwd, url: fetchUrl }, } : { uri: url }; } diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts index dabc896e2ded..837b4fbb0ae4 100644 --- a/apps/mobile/src/lib/videoPreviewSource.ts +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -1,4 +1,9 @@ -import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; +import { + SOURCE_CONTROL_MEDIA_ASSET, + type AssetResource, + type ChatFileAttachment, + type EnvironmentId, +} from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; import type { DraftComposerFileAttachment } from "./composerImages"; @@ -17,7 +22,13 @@ export type MediaVideoPreviewSource = { readonly environmentId: EnvironmentId; readonly resource: Extract< AssetResource, - { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "github-media" } + { + readonly _tag: + | "attachment" + | "media-file" + | "draft-workspace-file" + | typeof SOURCE_CONTROL_MEDIA_ASSET; + } >; } ); @@ -45,10 +56,10 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string source.resource.path, source.srcFragment ?? "", ] - : source.resource._tag === "github-media" + : source.resource._tag === SOURCE_CONTROL_MEDIA_ASSET ? [ "media-video", - "github-media", + SOURCE_CONTROL_MEDIA_ASSET, source.environmentId, source.resource.cwd, source.resource.url, diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 5b63d61d80cb..0fb9f4eee5c8 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -4,7 +4,9 @@ import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeFSP from "node:fs/promises"; import { AssetAccessError, + AssetCreateUrlInput, AssetPreviewTypeValidationError, + SOURCE_CONTROL_MEDIA_ASSET, ThreadId, TurnItemId, } from "@t3tools/contracts"; @@ -15,12 +17,11 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http"; +import { HttpClientRequest, HttpClientResponse, HttpServerResponse } from "effect/http"; import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -33,8 +34,10 @@ import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, readMcpAppHtml, resolveAsset } from "./AssetAccess.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; -import { githubMediaResponse } from "./GitHubMediaFetch.ts"; +import type { SourceControlDriverInstance } from "@t3tools/source-control-core/server/driver"; +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; +import { sourceControlMediaResponse } from "./sourceControlMediaResponse.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; vi.mock("node:fs/promises", async (importOriginal) => { @@ -178,53 +181,6 @@ describe("AssetAccess", () => { ); }); - it.effect("loads private media immediately after login with the GitHub credential", () => { - let lookups = 0; - const authorizations: Array = []; - return Effect.gen(function* () { - const asset = { - url: "https://raw.githubusercontent.com/owner/repo/main/shot.png", - cwd: "/repo", - expiresAt: Number.MAX_SAFE_INTEGER, - }; - expect((yield* githubMediaResponse(asset, {})).status).toBe(404); - expect((yield* githubMediaResponse(asset, {})).status).toBe(200); - expect((yield* githubMediaResponse(asset, {})).status).toBe(200); - expect(lookups).toBe(3); - expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]); - }).pipe( - Effect.provide( - Layer.mock(GitHubCredentials.GitHubCredentials)({ - get: (host) => - ++lookups === 1 - ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })) - : Effect.succeed({ - host, - token: Redacted.make("signed-in"), - source: "gh" as const, - fingerprint: "fingerprint", - }), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - authorizations.push(request.headers.authorization); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(null, { - status: request.headers.authorization ? 200 : 404, - headers: { "content-type": "image/png" }, - }), - ), - ); - }), - ), - Effect.scoped, - ); - }); - it.effect("serves an image a tool returned inline from the stored item", () => Effect.gen(function* () { const resource = { @@ -994,51 +950,66 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("serves GitHub-hosted pull request media through the repository's credential", () => - Effect.gen(function* () { - const resolve = (relativeUrl: string) => { - const suffix = relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separator = suffix.indexOf("/"); - return resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); - }; - const issue = (url: string) => - issueAssetUrl({ resource: { _tag: "github-media", cwd: "/repo", url } }); - - const attachment = yield* issue( - "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", - ); - expect(attachment.relativeUrl.endsWith("/1a1842fb-6383-492f-873c-57aa0033fa6c")).toBe(true); - expect(yield* resolve(attachment.relativeUrl)).toEqual({ - kind: "github-media", - url: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", - cwd: "/repo", - expiresAt: attachment.expiresAt, + it.effect("serves a host's credentialed media through that host's driver", () => { + const fetched: Array<{ url: string; headers: Readonly> }> = []; + const url = "https://github.com/owner/repo/blob/main/docs/shot.png"; + const media = sourceControlClients.mediaUrl(url)!; + const layerDrivers = Layer.succeed(BuiltInDrivers.SourceControlDrivers, [ + { + driver: { kind: media.definition.kind, definition: media.definition }, + instance: { + sourceControl: {} as SourceControlDriverInstance["sourceControl"], + discovery: {} as SourceControlDriverInstance["discovery"], + pullRequests: null, + fetchMedia: (request) => + Effect.sync(() => { + fetched.push(request); + return HttpClientResponse.fromWeb( + HttpClientRequest.get(request.url), + new Response("bytes", { status: 206, headers: { "content-length": "5" } }), + ); + }), + }, + }, + ]); + return Effect.gen(function* () { + // Every client and server release spells the resource this way on the wire. + const { resource } = yield* Schema.decodeUnknownEffect(AssetCreateUrlInput)({ + resource: { _tag: "github-media", cwd: "/repo", url }, }); - - const committed = yield* issue("https://github.com/owner/repo/blob/main/docs/shot.png"); - expect(yield* resolve(committed.relativeUrl)).toMatchObject({ - url: "https://raw.githubusercontent.com/owner/repo/main/docs/shot.png", + const issued = yield* issueAssetUrl({ resource }); + const suffix = issued.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); + expect(asset).toEqual({ + kind: "source-control-media", + url: media.fetchUrl, + cwd: "/repo", + expiresAt: issued.expiresAt, }); + if (asset?.kind !== "source-control-media") return; - const legacy = yield* issue("https://github.com/owner/repo/assets/45952064/1a1842fb"); - expect(yield* resolve(legacy.relativeUrl)).toMatchObject({ - url: "https://github.com/owner/repo/assets/45952064/1a1842fb", - }); - const lfs = yield* issue("https://media.githubusercontent.com/media/owner/repo/main/a.mp4"); - expect(yield* resolve(lfs.relativeUrl)).toMatchObject({ - url: "https://media.githubusercontent.com/media/owner/repo/main/a.mp4", + const response = yield* sourceControlMediaResponse(asset, { + range: "bytes=0-4", + cookie: "session", }); - const awkward = yield* issue("https://raw.githubusercontent.com/o/r/main/100%.png"); - expect(awkward.relativeUrl.endsWith("/100%25.png")).toBe(true); + expect(fetched).toEqual([{ url: media.fetchUrl, headers: { range: "bytes=0-4" } }]); + expect(response.status).toBe(206); + // The upstream sent no type, so it comes from the file name the host gave the media. + expect(response.headers["content-type"]).toBe("image/png"); + expect(response.headers["content-length"]).toBe("5"); + }).pipe(Effect.provide(Layer.merge(layerTest, layerDrivers)), Effect.scoped); + }); + it.effect("refuses media no host keeps behind a credential, without echoing the URL", () => + Effect.gen(function* () { for (const url of [ "https://example.com/shot.png", "https://example.com/shot.png?token=private-media-token", - "http://github.com/user-attachments/assets/1a1842fb", - "https://github.com/owner/repo/pull/1", - "https://github.com/owner/repo/blob/main/", ]) { - const error = yield* issue(url).pipe(Effect.flip); + const error = yield* issueAssetUrl({ + resource: { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: "/repo", url }, + }).pipe(Effect.flip); expect(error._tag).toBe("AssetGitHubMediaUrlValidationError"); const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(AssetAccessError))(error); expect(encoded).not.toContain(url); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index cc9621b962c2..2d53da803922 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,7 +1,7 @@ import type { AssetResource } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, - AssetGitHubMediaUrlValidationError, + AssetSourceControlMediaUrlValidationError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, @@ -13,6 +13,7 @@ import { AssetWorkspacePathValidationError, AssetWorkspaceResolutionError, AssetWorkspaceRootNormalizationError, + SOURCE_CONTROL_MEDIA_ASSET, ThreadId, ToolActivityNativeAppReference, TurnItemId, @@ -30,7 +31,6 @@ import { readImageDimensions, type ImageDimensions, } from "@t3tools/shared/imageDimensions"; -import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; import { isProjectFaviconPath, @@ -64,6 +64,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import { isWithinRoot } from "../workspace/KnownWorkspaces.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { extractIcnsPng } from "./icns.ts"; import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -160,8 +161,8 @@ const AssetClaimsSchema = Schema.Union([ }), Schema.Struct({ version: Schema.Literal(1), - kind: Schema.Literal("github-media"), - /** Already narrowed to a GitHub media host at mint time; the signature is what keeps it there. */ + kind: Schema.Literal(SOURCE_CONTROL_MEDIA_ASSET), + /** A host's `fetchUrl`, narrowed at mint time; the signature is what keeps it there. */ url: Schema.String, cwd: Schema.String, expiresAt: Schema.Number, @@ -189,7 +190,7 @@ export type ResolvedAsset = readonly mimeType: string; } | { - readonly kind: "github-media"; + readonly kind: "source-control-media"; readonly url: string; readonly cwd: string; readonly expiresAt: number; @@ -803,19 +804,19 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i fileName = "native-app-icon.png"; break; } - case "github-media": { - const fetchUrl = githubMediaFetchUrl(input.resource.url); - if (fetchUrl === null) { - return yield* new AssetGitHubMediaUrlValidationError({}); + case SOURCE_CONTROL_MEDIA_ASSET: { + const media = sourceControlClients.mediaUrl(input.resource.url); + if (media === null) { + return yield* new AssetSourceControlMediaUrlValidationError({}); } claims = { version: 1, - kind: "github-media", - url: fetchUrl, + kind: SOURCE_CONTROL_MEDIA_ASSET, + url: media.fetchUrl, cwd: input.resource.cwd, expiresAt, }; - fileName = githubMediaFileName(fetchUrl); + fileName = media.fileName; break; } } @@ -926,9 +927,9 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null; } - if (claims.kind === "github-media") { + if (claims.kind === SOURCE_CONTROL_MEDIA_ASSET) { return { - kind: "github-media", + kind: "source-control-media", url: claims.url, cwd: claims.cwd, expiresAt: claims.expiresAt, diff --git a/apps/server/src/assets/GitHubMediaFetch.ts b/apps/server/src/assets/GitHubMediaFetch.ts deleted file mode 100644 index 668f91b3d78a..000000000000 --- a/apps/server/src/assets/GitHubMediaFetch.ts +++ /dev/null @@ -1,138 +0,0 @@ -import * as Mime from "effect/http/Mime"; -import { githubMediaFileName } from "@t3tools/shared/githubMedia"; -import * as Clock from "effect/Clock"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Redacted from "effect/Redacted"; -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, - HttpServerResponse, - type HttpClientResponse, -} from "effect/http"; - -import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; - -/** - * Everything a redirect leads to — the presigned object stores GitHub hands assets off to — - * authorizes with its own signature, and some of them reject a request that also carries a - * bearer token. - */ -const CREDENTIALED_HOSTS = new Set([ - "github.com", - "www.github.com", - "raw.githubusercontent.com", - "media.githubusercontent.com", -]); -const isCredentialedHost = (url: string) => { - try { - return CREDENTIALED_HOSTS.has(new URL(url).hostname.toLowerCase()); - } catch { - return false; - } -}; - -const MAX_REDIRECTS = 3; -const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; -const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const; -const FORWARDED_RESPONSE_HEADERS = [ - "content-type", - "content-length", - "content-range", - "accept-ranges", - "etag", - "last-modified", -] as const; -const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i; -const SVG_CONTENT_TYPE = "image/svg+xml"; -// An SVG is a document: same policy the asset route gives a workspace SVG, so one embedded in a -// body cannot run script against this origin. -const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; - -const githubToken = GitHubCredentials.GitHubCredentials.pipe( - Effect.flatMap((credentials) => credentials.get("github.com")), - Effect.map((credential) => credential.token), - Effect.orElseSucceed(() => null), -); - -const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedirects")(function* ( - url: string, - headers: Record, - token: Redacted.Redacted | null, -) { - const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); - let target = url; - for (let hop = 0; ; hop += 1) { - const authorization = - token !== null && isCredentialedHost(target) ? `Bearer ${Redacted.value(token)}` : null; - const response: HttpClientResponse.HttpClientResponse = yield* httpClient - .execute( - HttpClientRequest.get(target).pipe( - HttpClientRequest.setHeaders({ - ...headers, - // The bytes are streamed straight through, so never let an encoding layer in. - "accept-encoding": "identity", - ...(authorization === null ? {} : { authorization }), - }), - ), - ) - .pipe(Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT)); - const location = response.headers.location; - if (response.status < 300 || response.status >= 400) return response; - if (!location || hop >= MAX_REDIRECTS) return null; - const next = new URL(location, target); - if (next.protocol !== "https:") return null; - target = next.toString(); - } -}); - -export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaResponse")(function* ( - asset: { readonly url: string; readonly cwd: string; readonly expiresAt: number }, - requestHeaders: Record, -) { - // Both media hosts are served by github.com's account, which is the host `gh` stores it under. - const token = yield* githubToken; - const forwarded: Record = {}; - for (const name of FORWARDED_REQUEST_HEADERS) { - const value = requestHeaders[name]; - if (value !== undefined) forwarded[name] = value; - } - const response = yield* fetchFollowingRedirects(asset.url, forwarded, token); - const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000); - const headers: Record = { - "cache-control": - remainingSeconds > 0 ? `private, max-age=${remainingSeconds}` : "private, no-store", - "x-content-type-options": "nosniff", - }; - if (response === null) return HttpServerResponse.empty({ status: 502, headers }); - // It carries none of the upstream entity headers: a `content-length` with no body behind it - // holds the connection open until the browser gives up on it. - if (response.status >= 400) { - return HttpServerResponse.empty({ - status: response.status >= 500 ? 502 : response.status, - headers, - }); - } - const upstreamType = - response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; - const contentType = MEDIA_CONTENT_TYPE_PATTERN.test(upstreamType) - ? upstreamType - : Option.getOrElse(Mime.getType(githubMediaFileName(asset.url)), () => "").toLowerCase(); - if (!MEDIA_CONTENT_TYPE_PATTERN.test(contentType)) { - return HttpServerResponse.empty({ status: 415, headers }); - } - for (const name of FORWARDED_RESPONSE_HEADERS) { - const value = response.headers[name]; - if (value !== undefined) headers[name] = value; - } - headers["content-type"] = contentType; - if (contentType === SVG_CONTENT_TYPE) { - headers["content-security-policy"] = SVG_CONTENT_SECURITY_POLICY; - } - return HttpServerResponse.stream(response.stream, { - status: response.status, - headers, - contentType, - }); -}); diff --git a/apps/server/src/assets/sourceControlMediaResponse.ts b/apps/server/src/assets/sourceControlMediaResponse.ts new file mode 100644 index 000000000000..9c49420670fb --- /dev/null +++ b/apps/server/src/assets/sourceControlMediaResponse.ts @@ -0,0 +1,80 @@ +import * as Mime from "effect/http/Mime"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpServerResponse } from "effect/http"; + +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; + +const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const; +const FORWARDED_RESPONSE_HEADERS = [ + "content-type", + "content-length", + "content-range", + "accept-ranges", + "etag", + "last-modified", +] as const; +const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i; +const SVG_CONTENT_TYPE = "image/svg+xml"; +// An SVG is a document: same policy the asset route gives a workspace SVG, so one embedded in a +// body cannot run script against this origin. +const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + +export const sourceControlMediaResponse = Effect.fn( + "SourceControlMediaResponse.sourceControlMediaResponse", +)(function* ( + asset: { readonly url: string; readonly cwd: string; readonly expiresAt: number }, + requestHeaders: Record, +) { + const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000); + const headers: Record = { + "cache-control": + remainingSeconds > 0 ? `private, max-age=${remainingSeconds}` : "private, no-store", + "x-content-type-options": "nosniff", + }; + const media = sourceControlClients.mediaUrl(asset.url); + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + const fetchMedia = drivers.find(({ driver }) => driver.kind === media?.definition.kind)?.instance + .fetchMedia; + if (media === null || fetchMedia === undefined) { + return HttpServerResponse.empty({ status: 404, headers }); + } + const forwarded: Record = {}; + for (const name of FORWARDED_REQUEST_HEADERS) { + const value = requestHeaders[name]; + if (value !== undefined) forwarded[name] = value; + } + const response = yield* fetchMedia({ url: asset.url, headers: forwarded }); + if (response === null) return HttpServerResponse.empty({ status: 502, headers }); + // It carries none of the upstream entity headers: a `content-length` with no body behind it + // holds the connection open until the browser gives up on it. + if (response.status >= 400) { + return HttpServerResponse.empty({ + status: response.status >= 500 ? 502 : response.status, + headers, + }); + } + const upstreamType = + response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + const contentType = MEDIA_CONTENT_TYPE_PATTERN.test(upstreamType) + ? upstreamType + : Option.getOrElse(Mime.getType(media.fileName), () => "").toLowerCase(); + if (!MEDIA_CONTENT_TYPE_PATTERN.test(contentType)) { + return HttpServerResponse.empty({ status: 415, headers }); + } + for (const name of FORWARDED_RESPONSE_HEADERS) { + const value = response.headers[name]; + if (value !== undefined) headers[name] = value; + } + headers["content-type"] = contentType; + if (contentType === SVG_CONTENT_TYPE) { + headers["content-security-policy"] = SVG_CONTENT_SECURITY_POLICY; + } + return HttpServerResponse.stream(response.stream, { + status: response.status, + headers, + contentType, + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 8871a5df3451..b6c6833a87d6 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -31,7 +31,7 @@ import { OtlpTracer, OtlpSerialization } from "effect/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts"; -import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; +import { sourceControlMediaResponse } from "./assets/sourceControlMediaResponse.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, @@ -432,10 +432,15 @@ export const layerAssetRoute = HttpRouter.add( headers: { "cache-control": "private, max-age=3600", "x-content-type-options": "nosniff" }, }); } - if (asset.kind === "github-media") { - return yield* githubMediaResponse(asset, request.headers).pipe( + if (asset.kind === "source-control-media") { + return yield* sourceControlMediaResponse(asset, request.headers).pipe( Effect.tapError((cause) => - Effect.logWarning("Failed to fetch GitHub media.", { url: asset.url, cause }), + // The URL can carry a token in its query string, and the error keeps the request it + // failed on; the host and the failure kind are enough to diagnose. + Effect.logWarning("Failed to fetch source control media.", { + host: URL.canParse(asset.url) ? new URL(asset.url).host : undefined, + reason: cause.reason._tag, + }), ), Effect.orElseSucceed(() => HttpServerResponse.empty({ diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 22a07e2a2317..72cd314f5e28 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -28,12 +28,8 @@ export function fromProviders( * @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => - driver.make.pipe(Effect.map((instance) => instance.pullRequests)), - ); - return fromProviders( - drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), - ); + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + return fromProviders(drivers.flatMap(({ instance }) => instance.pullRequests ?? [])); }); export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ff0aca4126bc..24ad96319c87 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -60,6 +60,7 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControl/sourceControlClients.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; @@ -332,9 +333,7 @@ const layerRepositoryIdentityResolver = Layer.effect( const registry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; return yield* RepositoryIdentityResolver.make({ refine: Effect.fn(function* (identity: RepositoryIdentity) { - for (const kind of SourceControlBuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS.map( - (driver) => driver.kind, - )) { + for (const { kind } of BUILT_IN_SOURCE_CONTROL_DRIVER_LIST) { const provider = yield* registry.get(kind); if (provider.refineRepositoryIdentity === undefined) continue; const refined = yield* provider.refineRepositoryIdentity({ diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 43f3a9ff8680..8337ac730d3f 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -23,6 +23,7 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; import * as GitHubRequestPolicy from "@t3tools/source-control-github/server/GitHubRequestPolicy"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; @@ -30,6 +31,7 @@ import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; +import * as BuiltInDriversTestkit from "./builtInDrivers.testkit.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; @@ -39,6 +41,7 @@ const layerSourceControlProviderRegistryTest = (input: { readonly process: Partial; }) => SourceControlProviderRegistry.layer.pipe( + Layer.provide(BuiltInDriversTestkit.layerOverHostServices), Layer.provide( Layer.mergeAll( ServerConfig.layerTest(process.cwd(), { @@ -50,6 +53,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), ServerSettings.ServerSettingsService.layerTest(), Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), + Layer.mock(GitHubCredentials.GitHubCredentials)({}), Layer.mock(GitHubRest.GitHubRest)({}), // The GitHub provider's GraphQL reads go through these; no test here makes one. Layer.mock(GitHubRequestPolicy.GitHubRequestPolicy)({}), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 80738fcf38a2..7d7c79990bda 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -39,6 +39,7 @@ import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/Git import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; +import * as BuiltInDriversTestkit from "./builtInDrivers.testkit.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -114,53 +115,59 @@ function makeRegistry(input: { return SourceControlProviderRegistry.make.pipe( Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerRegistry, - layerProcess, - ServerSourceControlHost.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerProcess, - layerRegistry, - ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), + BuiltInDriversTestkit.layerOverHostServices.pipe( + Layer.provideMerge( + Layer.mergeAll( + NodeServices.layer, + layerRegistry, + layerProcess, + ServerSourceControlHost.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerProcess, + layerRegistry, + ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + ), + ), ), + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), + Layer.mock(BitbucketApi.BitbucketApi)({}), + Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), + input.settings + ? Layer.mock(ServerSettings.ServerSettingsService)(input.settings) + : ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), + Layer.mock(GitHubCredentials.GitHubCredentials)({}), + // The GitHub provider's GraphQL reads go through these; no test here makes one. + Layer.mock(GitHubRequestPolicy.GitHubRequestPolicy)({}), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die(new Error("Unexpected GitHub GraphQL read"))), + ), + Layer.mock(GitHubRest.GitHubRest)({ + // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. + credential: (host) => + Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), + ...input.githubApi, + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), + Layer.mock(ForgejoCli.ForgejoCli)({ + listLogins: () => Effect.succeed([]), + ...input.forgejo, + }), + Layer.mock(GitCafeApi.GitCafeApi)({}), + Layer.mock(GitCafeCredentials.GitCafeCredentials)({ + cliEnv: () => Effect.succeed({}), + }), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-registry-test-", + }).pipe(Layer.provide(NodeServices.layer)), ), ), - Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), - Layer.mock(BitbucketApi.BitbucketApi)({}), - Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), - input.settings - ? Layer.mock(ServerSettings.ServerSettingsService)(input.settings) - : ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), - // The GitHub provider's GraphQL reads go through these; no test here makes one. - Layer.mock(GitHubRequestPolicy.GitHubRequestPolicy)({}), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die(new Error("Unexpected GitHub GraphQL read"))), - ), - Layer.mock(GitHubRest.GitHubRest)({ - // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. - credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), - ...input.githubApi, - }), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), - Layer.mock(ForgejoCli.ForgejoCli)({ - listLogins: () => Effect.succeed([]), - ...input.forgejo, - }), - Layer.mock(GitCafeApi.GitCafeApi)({}), - Layer.mock(GitCafeCredentials.GitCafeCredentials)({ - cliEnv: () => Effect.succeed({}), - }), - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-registry-test-", - }).pipe(Layer.provide(NodeServices.layer)), ), ), ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 5ea6c4037c9c..e08b7a7e6fb5 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -358,16 +358,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit ); export const make = Effect.gen(function* () { - const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => - driver.make.pipe( - Effect.map((instance): SourceControlProviderRegistration => ({ - kind: driver.kind, - provider: instance.sourceControl, - discovery: instance.discovery, - })), - ), + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + return yield* makeWithProviders( + drivers.map(({ driver, instance }): SourceControlProviderRegistration => ({ + kind: driver.kind, + provider: instance.sourceControl, + discovery: instance.discovery, + })), ); - return yield* makeWithProviders(drivers); }); export const layer = Layer.effect(SourceControlProviderRegistry, make); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 14615a39856d..4a9105e9f5d9 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -101,6 +101,51 @@ function layer(input: { : layerService.pipe(Layer.provideMerge(NodeServices.layer)); } +const lookupFailingWith = (cause: unknown, detail: string) => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* service + .lookupRepository({ provider: SourceControlProviderKind.make("bitbucket"), repository: "x" }) + .pipe(Effect.flip); + }).pipe( + Effect.provide( + layer({ + provider: makeProvider({ + getRepositoryCloneUrls: (input) => + Effect.fail( + new SourceControlProviderError({ + provider: SourceControlProviderKind.make("bitbucket"), + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + detail, + cause, + }), + ), + }), + }), + ), + ); + +it.effect("tells the user a host's fixed explanation of a failed repository lookup", () => + Effect.gen(function* () { + const error = yield* lookupFailingWith( + { userFacingDetail: "Repositories are named workspace/repository." }, + "Repositories are named workspace/repository.", + ); + assert.strictEqual(error.detail, "Repositories are named workspace/repository."); + }), +); + +it.effect("keeps a host's raw response text out of a failed repository lookup", () => + Effect.gen(function* () { + const error = yield* lookupFailingWith( + new Error("upstream"), + "internal token=abc", + ); + assert.strictEqual(error.detail, "The source control operation could not be completed."); + }), +); + it.effect("clones a looked-up repository after creating missing parent directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index cba0df9d9056..ceb9ec33288d 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -30,14 +30,13 @@ import { type GitCloneProgressLine, } from "../project/gitCloneProgress.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { cloneUrlForProtocol, cloneUrlProtocol } from "@t3tools/shared/cloneUrl"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); const isSourceControlProviderError = Schema.is(SourceControlProviderError); const isGitCommandError = Schema.is(GitCommandError); -const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, @@ -95,11 +94,9 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi operation, provider, detail: - isSourceControlProviderError(cause) && - cause.provider === "bitbucket" && - isBitbucketRepositoryLocatorError(cause.cause) - ? BitbucketApi.BitbucketRepositoryLocatorError.detail - : "The source control operation could not be completed.", + (isSourceControlProviderError(cause) + ? SourceControlProvider.userFacingSourceControlDetail(cause.cause) + : undefined) ?? "The source control operation could not be completed.", cause, }), ); diff --git a/apps/server/src/sourceControl/builtInDrivers.testkit.ts b/apps/server/src/sourceControl/builtInDrivers.testkit.ts new file mode 100644 index 000000000000..e3d314336b0e --- /dev/null +++ b/apps/server/src/sourceControl/builtInDrivers.testkit.ts @@ -0,0 +1,40 @@ +import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; +import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; +import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; +import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; +import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; +import type { + SourceControlDriver, + SourceControlDriverInstance, +} from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as BuiltInDrivers from "./builtInDrivers.ts"; + +const entry = ( + driver: SourceControlDriver, + instance: SourceControlDriverInstance, +): BuiltInDrivers.SourceControlDriverEntry => ({ driver, instance }); + +/** Every built-in driver over whatever stands in for its host's own services. */ +export const layerOverHostServices = Layer.effect( + BuiltInDrivers.SourceControlDrivers, + Effect.all([ + GitHubDriver.makeInstance.pipe(Effect.map((instance) => entry(GitHubDriver.driver, instance))), + GitLabDriver.makeInstance.pipe(Effect.map((instance) => entry(GitLabDriver.driver, instance))), + GitCafeDriver.makeInstance.pipe( + Effect.map((instance) => entry(GitCafeDriver.driver, instance)), + ), + ForgejoDriver.makeInstance.pipe( + Effect.map((instance) => entry(ForgejoDriver.driver, instance)), + ), + BitbucketDriver.makeInstance.pipe( + Effect.map((instance) => entry(BitbucketDriver.driver, instance)), + ), + AzureDevOpsDriver.makeInstance.pipe( + Effect.map((instance) => entry(AzureDevOpsDriver.driver, instance)), + ), + ]), +); diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 7941c7f3eadc..26bc4d4da213 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -1,37 +1,31 @@ -import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; -import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import type { + SourceControlDriver, + SourceControlDriverInstance, +} from "@t3tools/source-control-core/server/driver"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; -import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST as drivers } from "./sourceControlClients.ts"; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControlClients.ts"; -export type BuiltInSourceControlDriversEnv = - (typeof drivers)[number] extends SourceControlDriver ? R : never; +export interface SourceControlDriverEntry { + readonly driver: Pick; + readonly instance: SourceControlDriverInstance; +} -export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< - SourceControlDriver -> = drivers; +/** Every driver's instance, in registry order. Everything that reads a host reads it here. */ +export class SourceControlDrivers extends Context.Service< + SourceControlDrivers, + ReadonlyArray +>()("t3/sourceControl/builtInDrivers/SourceControlDrivers") {} -export const layer = Layer.mergeAll( - // `GitHubRest.layerWithDependencies` carries the quota reserve and rate-limit pause every GitHub - // reader shares, so the server builds it once, here. - GitHubPullRequestApi.layer.pipe(Layer.provideMerge(GitHubRest.layerWithDependencies)), - AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), - BitbucketPullRequestApi.layer.pipe( - Layer.provideMerge(BitbucketApi.layer.pipe(Layer.provide(SourceControlRateLimit.layer))), - ), - ForgejoCli.layer, - GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), - GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), -).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); +const make = Effect.forEach(BUILT_IN_SOURCE_CONTROL_DRIVER_LIST, (driver) => + driver.make.pipe(Effect.map((instance) => ({ driver, instance }))), +); + +export const layer = Layer.effect(SourceControlDrivers, make).pipe( + Layer.provideMerge(ServerSourceControlHost.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9a72a501f43f..cc70767a8d38 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -85,6 +85,7 @@ import { FilesystemBrowseError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, + SOURCE_CONTROL_MEDIA_ASSET, ChatAttachmentId, PersistChatAttachmentsError, RpcClientId, @@ -2772,7 +2773,7 @@ export const layerWsRpc = ( input.resource._tag === "attachment" || input.resource._tag === "native-app-icon" || input.resource._tag === "tool-output-image" || - input.resource._tag === "github-media" || + input.resource._tag === SOURCE_CONTROL_MEDIA_ASSET || // A network path is served only from the thread's project, looked up below. (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path) && diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e46e3092d6b0..2a3c71583427 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -8,7 +8,11 @@ import { } from "@t3tools/shared/markdownLinks"; import { isHostPath, resolveWorkspaceFilePath } from "@t3tools/shared/path"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; -import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + AuthOrchestrationOperateScope, + SOURCE_CONTROL_MEDIA_ASSET, +} from "@t3tools/contracts"; import { CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, @@ -45,7 +49,6 @@ import { type ServerProviderSkill, type ThreadPullRequestKey, } from "@t3tools/contracts"; -import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -234,9 +237,9 @@ interface ChatMarkdownProps { onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable; renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; - /** Loads GitHub-hosted media through `cwd`'s GitHub credential, which a private repository's - uploads need; without it those images and videos load unauthenticated and 404. */ - githubMedia?: boolean | undefined; + /** Loads media a source control host keeps behind a credential through the server, which holds + `cwd`'s; without it a private repository's uploads load unauthenticated and 404. */ + sourceControlMedia?: boolean | undefined; headingLevelOffset?: number | undefined; } @@ -448,7 +451,7 @@ function useChatMarkdownState({ onImageExpand, renderContextReference, headingLevelOffset = 0, - githubMedia = false, + sourceControlMedia = false, }: ChatMarkdownProps) { const resolvedTheme = useRenderedAppearance(); const markdownRef = useRef(null); @@ -885,7 +888,7 @@ function useChatMarkdownState({ environmentId, expandMedia, fileLinkChip, - githubMedia, + sourceControlMedia, renderContextReference, headingLevelOffset, imageBaseDir, @@ -1352,7 +1355,7 @@ const CHAT_MARKDOWN_COMPONENTS = { expandMedia, cwd, environmentId, - githubMedia, + sourceControlMedia, imageBaseDir, threadRef, workspaceMutationId, @@ -1384,19 +1387,22 @@ const CHAT_MARKDOWN_COMPONENTS = { const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); const kind = mediaKindFromPath(classifiedSrc) ?? "image"; const directUri = imageSource._tag === "Direct" ? imageSource.uri : null; - const githubMediaUrl = - directUri === null ? null : githubMediaFetchUrl(resolveProtocolRelativeMediaUrl(directUri)); + const hostMediaUrl = + directUri === null + ? null + : (sourceControlClients.mediaUrl(resolveProtocolRelativeMediaUrl(directUri))?.fetchUrl ?? + null); if ( - githubMedia && + sourceControlMedia && cwd !== undefined && environmentId !== null && directUri !== null && - githubMediaUrl !== null + hostMediaUrl !== null ) { return ( ); diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx index f0601234628e..d74746378d93 100644 --- a/apps/web/src/components/chat/ChatMarkdownMedia.tsx +++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx @@ -1,6 +1,6 @@ import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import type { AssetResource, EnvironmentId, SOURCE_CONTROL_MEDIA_ASSET } from "@t3tools/contracts"; import { TriangleAlertIcon } from "lucide-react"; import { createContext, @@ -312,7 +312,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props | "attachment" | "workspace-file" | "media-file" - | "github-media" + | typeof SOURCE_CONTROL_MEDIA_ASSET | "tool-output-image"; } >; diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 4d25c8011d13..91dec8acdaca 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,7 +1,12 @@ import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"; import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images"; -import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; -import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + SOURCE_CONTROL_MEDIA_ASSET, + type AssetResource, + type EnvironmentId, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { createContext, useContext } from "react"; import type { Options as ReactMarkdownOptions } from "react-markdown"; @@ -18,7 +23,7 @@ export const PullRequestMarkdownContext = createContext<{ threadRef: ScopedThreadRef | null; } | null>(null); -function PullRequestGitHubVideo({ +function PullRequestHostVideo({ environmentId, cwd, url, @@ -29,7 +34,7 @@ function PullRequestGitHubVideo({ url: string; fetchUrl: string; }) { - const resource: AssetResource = { _tag: "github-media", cwd, url: fetchUrl }; + const resource: AssetResource = { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd, url: fetchUrl }; const assetUrl = useAssetUrlState(environmentId, resource); const refreshAssetUrl = useAssetUrlRefresh(environmentId, resource); const src = @@ -85,19 +90,19 @@ export function PullRequestMarkdown({ pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} environmentId={environmentId} extraRemarkPlugins={extraRemarkPlugins} - githubMedia + sourceControlMedia /> ); } - const githubMediaUrl = segment.media === "video" ? githubMediaFetchUrl(segment.url) : null; - if (githubMediaUrl !== null) { + const hostMedia = sourceControlClients.mediaUrl(segment.url); + if (segment.media === "video" && hostMedia !== null) { return ( - ); } @@ -125,7 +130,9 @@ export function PullRequestMarkdown({ className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm hover:bg-muted/60" > - Open attachment on GitHub + + Open attachment on {hostMedia?.definition.label ?? new URL(segment.url).hostname} + ); diff --git a/knip.jsonc b/knip.jsonc index 13885f968c60..aba0742d2424 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -44,7 +44,6 @@ "scripts/cli.ts", "scripts/evaluate-thread-titles.ts", "scripts/fetch-expo-schema.ts", - "scripts/measure-pr-preview.ts", "scripts/probe-claude-fork-local-rollback-replay.ts", "scripts/update-test-shard-weights.ts", "scripts/verify-background-live.ts", @@ -135,7 +134,7 @@ }, "packages/source-control-github": { // graphqlgen reads its config; the generated operations export every selection's class. - "entry": ["graphql.config.ts"], + "entry": ["graphql.config.ts", "scripts/measure-pr-preview.ts"], "ignoreIssues": { "**/*.graphql.ts": ["exports", "types", "namespaceMembers"] }, }, }, diff --git a/packages/client-runtime/src/pullRequestMarkdown.ts b/packages/client-runtime/src/pullRequestMarkdown.ts index 67ac68e92619..8e342c425dba 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.ts @@ -1,3 +1,5 @@ +import { sourceControlClients } from "./sourceControlClients.ts"; + /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ export type PullRequestBodySegment = | { readonly id: string; readonly kind: "markdown"; readonly text: string } @@ -21,7 +23,6 @@ const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|svg)(?:$ const MARKDOWN_LINK_PATTERN = /^\[[^\]]*\]\((https?:\/\/[^\s)]+)\)$/u; const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu; -const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu; const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu; const STANDALONE_VIDEO_TAG_PATTERN = /^\s*()( +// The tag predates the rename and stays: clients on either side of an update decode it. +export class AssetSourceControlMediaUrlValidationError extends Schema.TaggedError()( "AssetGitHubMediaUrlValidationError", {}, ) { override get message(): string { - return "Only media hosted by GitHub can be fetched with a GitHub credential."; + return "Only media a source control host keeps behind a credential can be fetched with one."; } } @@ -314,7 +321,7 @@ export const AssetAccessError = Schema.Union([ AssetProjectFaviconResolutionError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, - AssetGitHubMediaUrlValidationError, + AssetSourceControlMediaUrlValidationError, AssetSigningKeyLoadError, ]); export type AssetAccessError = typeof AssetAccessError.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 3fe44db4f345..0fcf52cc8805 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -55,10 +55,6 @@ "types": "./src/git.ts", "import": "./src/git.ts" }, - "./githubMedia": { - "types": "./src/githubMedia.ts", - "import": "./src/githubMedia.ts" - }, "./sourceControl": { "types": "./src/sourceControl.ts", "import": "./src/sourceControl.ts" diff --git a/packages/source-control-azure-devops/src/server/driver.ts b/packages/source-control-azure-devops/src/server/driver.ts index 1d218c303854..d1758ad8ba8d 100644 --- a/packages/source-control-azure-devops/src/server/driver.ts +++ b/packages/source-control-azure-devops/src/server/driver.ts @@ -1,17 +1,28 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { definition } from "../client/definition.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; +const services = AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)); + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: AzureDevOpsSourceControlProvider.make, + discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), + pullRequests: AzureDevOpsPullRequestProvider.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: AzureDevOpsSourceControlProvider.make, - discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), - pullRequests: AzureDevOpsPullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 07c7064b4498..21aa02f34bbb 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -77,6 +77,10 @@ export class BitbucketRepositoryLocatorError extends Schema.TaggedError + isRepositoryLocatorError(error) ? error.detail : fallback; + function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { return { provider: SourceControlProviderKind.make("bitbucket"), @@ -122,7 +129,7 @@ export const make = Effect.gen(function* () { repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, ), - detail: "Failed to get repository clone URLs.", + detail: repositoryDetail(error, "Failed to get repository clone URLs."), cause: error, }), ), @@ -138,7 +145,7 @@ export const make = Effect.gen(function* () { repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, ), - detail: "Failed to create repository.", + detail: repositoryDetail(error, "Failed to create repository."), cause: error, }), ), diff --git a/packages/source-control-bitbucket/src/server/driver.ts b/packages/source-control-bitbucket/src/server/driver.ts index 19d35d82015c..5cec134e8e7f 100644 --- a/packages/source-control-bitbucket/src/server/driver.ts +++ b/packages/source-control-bitbucket/src/server/driver.ts @@ -1,17 +1,32 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { definition } from "../client/definition.ts"; +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; +// Every setting the API reads from the environment is optional, so reading them cannot fail. +const services = BitbucketPullRequestApi.layer.pipe( + Layer.provideMerge(BitbucketApi.layer), + Layer.orDie, +); + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: BitbucketSourceControlProvider.make, + discovery: BitbucketSourceControlProvider.makeDiscovery, + pullRequests: BitbucketPullRequestProvider.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: BitbucketSourceControlProvider.make, - discovery: BitbucketSourceControlProvider.makeDiscovery, - pullRequests: BitbucketPullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 9a13dd20a4ee..35cc5ddaeba3 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -74,6 +74,16 @@ export interface ChangeRequestCheckoutInput { readonly repositoryUrl?: string | null | undefined; } +/** Media a host serves only to a request carrying a credential, as `mediaUrl` reads it. */ +export interface SourceControlMediaUrl { + /** The canonical URL to fetch, the same for every spelling of one file. */ + readonly fetchUrl: string; + /** A file name for the media, safe to put in a path or a download. */ + readonly fileName: string; + /** Set when the URL alone says what the media is, as an extensionless upload can. */ + readonly kind?: "image" | "video"; +} + export interface SourceControlClientDefinition { readonly kind: SourceControlProviderKind; readonly label: string; @@ -141,6 +151,11 @@ export interface SourceControlClientDefinition { * the provider rather than from the remote. */ readonly sshRemoteHidesWebHost?: boolean; + /** + * Null when `url` is not media this host keeps behind a credential. A client cannot load such + * media itself, so it asks the server, whose driver for this host fetches it. + */ + readonly mediaUrl?: (url: URL) => SourceControlMediaUrl | null; readonly settings?: SourceControlHostSettingsSchema; } @@ -342,6 +357,10 @@ export interface ParsedChangeRequest { readonly link: ParsedChangeRequestUrl; } +export interface SourceControlMedia extends SourceControlMediaUrl { + readonly definition: SourceControlClientDefinition; +} + export interface SourceControlClientRegistry { readonly definitions: ReadonlyArray; readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; @@ -370,6 +389,8 @@ export interface SourceControlClientRegistry { identity: ChangeRequestHostIdentity, kind: Kind, ) => string | Kind; + /** The first host, in registry order, that keeps `url` behind a credential. */ + readonly mediaUrl: (url: string) => SourceControlMedia | null; } function httpUrl(value: string): URL | null { @@ -494,6 +515,15 @@ export function makeSourceControlClientRegistry( changeRequestHost: (identity, kind) => (kind === undefined ? undefined : byKind.get(kind)?.changeRequestHost?.(identity)) ?? projectChangeRequestHost(identity, kind), + mediaUrl: (value) => { + const url = httpUrl(value); + if (url === null) return null; + for (const definition of definitions) { + const media = definition.mediaUrl?.(url); + if (media != null) return { ...media, definition }; + } + return null; + }, }; } diff --git a/packages/source-control-core/src/server/SourceControlProvider.ts b/packages/source-control-core/src/server/SourceControlProvider.ts index e149fcd202a2..23e26dcf0d7f 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -38,6 +38,19 @@ export interface SourceControlRefSelector { const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; +/** + * The fixed product text a provider failure carries for the user, if it has one. Any other + * detail may be upstream response text, so callers show a generic message instead. + */ +export function userFacingSourceControlDetail(cause: unknown): string | undefined { + return typeof cause === "object" && + cause !== null && + "userFacingDetail" in cause && + typeof cause.userFacingDetail === "string" + ? cause.userFacingDetail + : undefined; +} + export function transportSafeSourceControlErrorValue(value: string): string { let printable = ""; for (const character of value) { diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index 360868770abf..9007dad84f0b 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -1,25 +1,76 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; -import type * as Effect from "effect/Effect"; +import type * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; import type { SourceControlClientDefinition } from "../client/definition.ts"; import type { SourceControlProviderDiscoverySpec } from "./discovery.ts"; import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; +import type * as SourceControlHost from "./SourceControlHost.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import type * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +export interface SourceControlMediaRequest { + /** A `fetchUrl` this host's definition returned from `mediaUrl`. */ + readonly url: string; + /** Request headers to forward, such as `range`. */ + readonly headers: Readonly>; +} export interface SourceControlDriverInstance { readonly sourceControl: SourceControlProvider.SourceControlProvider["Service"]; readonly discovery: SourceControlProviderDiscoverySpec; /** Null for a host whose pull requests this build cannot read. */ readonly pullRequests: PullRequestProviderApi | null; + /** + * Fetches media the definition's `mediaUrl` claimed, with the credential the host serves it + * to. Null when the fetch went nowhere a response could come from, such as a redirect loop. + */ + readonly fetchMedia?: ( + request: SourceControlMediaRequest, + ) => Effect.Effect< + HttpClientResponse.HttpClientResponse | null, + HttpClientError.HttpClientError, + Scope.Scope + >; } -export interface SourceControlDriver { +/** What the server gives every driver. A driver builds anything else it needs itself. */ +export type SourceControlDriverServices = + | SourceControlHost.SourceControlHost + | SourceControlRateLimit.SourceControlRateLimit + | HttpClient.HttpClient + | FileSystem.FileSystem + | Crypto.Crypto; + +export interface SourceControlDriver { readonly kind: SourceControlProviderKind; readonly definition: SourceControlClientDefinition; - readonly make: Effect.Effect; + /** + * Run once per server, in a scope that lasts as long as the server. State a host shares across + * everything that talks to it, such as GitHub's quota reserve and rate-limit pause, lives in + * what this builds, so building it twice would split that state. + */ + readonly make: Effect.Effect< + SourceControlDriverInstance, + never, + SourceControlDriverServices | Scope.Scope + >; } -export const defineSourceControlDriver = ( - driver: SourceControlDriver, -): SourceControlDriver => driver; +export const defineSourceControlDriver = (driver: SourceControlDriver): SourceControlDriver => + driver; + +/** + * `make` over a host's own `services`, built into the caller's scope so they live as long as the + * instance that holds them. + */ +export const withOwnServices = ( + make: Effect.Effect, + services: Layer.Layer, +): Effect.Effect | RIn | Scope.Scope> => + Layer.build(services).pipe(Effect.flatMap((context) => make.pipe(Effect.provide(context)))); diff --git a/packages/source-control-forgejo/src/server/driver.ts b/packages/source-control-forgejo/src/server/driver.ts index 7324741656c1..b96b50a4f8a1 100644 --- a/packages/source-control-forgejo/src/server/driver.ts +++ b/packages/source-control-forgejo/src/server/driver.ts @@ -1,17 +1,26 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; import { definition } from "../client/definition.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; +const services = ForgejoCli.layer; + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: ForgejoSourceControlProvider.make, + discovery: ForgejoSourceControlProvider.makeDiscovery, + pullRequests: ForgejoPullRequestProvider.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: ForgejoSourceControlProvider.make, - discovery: ForgejoSourceControlProvider.makeDiscovery, - pullRequests: ForgejoPullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts index dca35f45b75a..3fdc129ac405 100644 --- a/packages/source-control-gitcafe/src/server/driver.ts +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -1,17 +1,28 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { definition } from "../client/definition.ts"; +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; +const services = GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)); + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: GitCafeSourceControlProvider.make, + discovery: GitCafeSourceControlProvider.makeDiscovery, + pullRequests: GitCafePullRequestProvider.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: GitCafeSourceControlProvider.make, - discovery: GitCafeSourceControlProvider.makeDiscovery, - pullRequests: GitCafePullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); diff --git a/apps/server/scripts/measure-pr-preview.ts b/packages/source-control-github/scripts/measure-pr-preview.ts similarity index 69% rename from apps/server/scripts/measure-pr-preview.ts rename to packages/source-control-github/scripts/measure-pr-preview.ts index 5a37b6858329..448984f5ce8b 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/packages/source-control-github/scripts/measure-pr-preview.ts @@ -1,30 +1,29 @@ -// Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 +// Run with: node packages/source-control-github/scripts/measure-pr-preview.ts owner/repo 123 456 // Numbers form a session with shared repository-permission caches. Browser and // service caches are excluded. Uses real GitHub reads, without a server or database. // Requests are counted at the HTTP client. A GraphQL request's cost is how far it moved GitHub's // `x-ratelimit-used` for the `graphql` resource, which is approximate when anything else spends // the same token's quota while this runs. import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as Effect from "effect/Effect"; import * as Console from "effect/Console"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubPullRequestProvider from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as ServerConfig from "../src/config.ts"; -import * as ServerSettings from "../src/serverSettings.ts"; -import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts"; -import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; -import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../src/vcs/VcsProcess.ts"; -import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; +import * as GitHubPullRequestApi from "../src/server/GitHubPullRequestApi.ts"; +import * as GitHubPullRequestProvider from "../src/server/GitHubPullRequestProvider.ts"; +import * as GitHubRest from "../src/server/GitHubRest.ts"; const [repository, ...numbers] = process.argv.slice(2); if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.test(number))) { - throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number..."); + throw new Error( + "Usage: node packages/source-control-github/scripts/measure-pr-preview.ts owner/repo number...", + ); } type Read = { graphqlRequests: number; restRequests: number; cost: number }; @@ -73,18 +72,42 @@ const measuredHttp = Layer.effect( }), ).pipe(Layer.provide(FetchHttpClient.layer)); -// The server's host port over default settings: gh's own account choice, no saved token. -const sourceControlHost = ServerSourceControlHost.layer.pipe( - Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), - Layer.provide(GitVcsDriver.layer), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-measure-pr-preview-" })), - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), -); +// Default settings, so `gh` picks the account and no saved token applies. Only the credential +// lookup runs a process, and it needs no more than its output. +const sourceControlHost = Layer.unwrap( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return TestSourceControlHost.layer({ + process: { + run: (input) => + Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make(input.command, [...input.args], { + cwd: input.spawnCwd ?? input.cwd, + env: input.env, + extendEnv: true, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr, stdoutTruncated: false, stderrTruncated: false }; + }), + ).pipe(Effect.orDie), + }, + }); + }), +).pipe(Layer.provideMerge(NodeServices.layer)); const services = GitHubPullRequestApi.layer.pipe( Layer.provideMerge(GitHubRest.layerWithDependencies), + Layer.provideMerge(SourceControlRateLimit.layer), Layer.provideMerge(measuredHttp), Layer.provideMerge(sourceControlHost), ); diff --git a/packages/source-control-github/src/client/definition.test.ts b/packages/source-control-github/src/client/definition.test.ts index c929be7167aa..30fcf7ccc177 100644 --- a/packages/source-control-github/src/client/definition.test.ts +++ b/packages/source-control-github/src/client/definition.test.ts @@ -121,3 +121,41 @@ describe("GitHub remotes", () => { expect(recognize("https://notgithub.example.com/owner/repo.git")).toBeNull(); }); }); + +describe("GitHub media URLs", () => { + const media = (url: string) => definition.mediaUrl(new URL(url)); + + it("fetches every spelling of an upload or committed file from one canonical URL", () => { + expect( + media("https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c"), + ).toEqual({ + fetchUrl: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", + fileName: "1a1842fb-6383-492f-873c-57aa0033fa6c", + kind: "video", + }); + expect(media("https://github.com/owner/repo/blob/main/docs/shot.png")).toEqual({ + fetchUrl: "https://raw.githubusercontent.com/owner/repo/main/docs/shot.png", + fileName: "shot.png", + }); + expect(media("https://github.com/owner/repo/assets/45952064/1a1842fb")?.fetchUrl).toBe( + "https://github.com/owner/repo/assets/45952064/1a1842fb", + ); + expect(media("https://media.githubusercontent.com/media/owner/repo/main/a.mp4")).toEqual({ + fetchUrl: "https://media.githubusercontent.com/media/owner/repo/main/a.mp4", + fileName: "a.mp4", + }); + expect(media("https://raw.githubusercontent.com/o/r/main/100%.png")?.fileName).toBe("100%.png"); + }); + + it("claims nothing a request without a credential loads just as well", () => { + for (const url of [ + "https://example.com/shot.png", + "http://github.com/user-attachments/assets/1a1842fb", + "https://github.com/owner/repo/pull/1", + "https://github.com/owner/repo/blob/main/", + "https://objects.githubusercontent.com/github-production-user-asset/shot.png", + ]) { + expect(media(url), url).toBeNull(); + } + }); +}); diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index c022ed824b41..86268f29d408 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -18,6 +18,8 @@ import { remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; +import { gitHubMediaUrl } from "./gitHubMedia.ts"; + const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; @@ -134,5 +136,6 @@ export const definition = defineSourceControlClient({ isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), + mediaUrl: gitHubMediaUrl, settings, }); diff --git a/packages/shared/src/githubMedia.ts b/packages/source-control-github/src/client/gitHubMedia.ts similarity index 51% rename from packages/shared/src/githubMedia.ts rename to packages/source-control-github/src/client/gitHubMedia.ts index 2d2797d09bc2..51b9e0598fa2 100644 --- a/packages/shared/src/githubMedia.ts +++ b/packages/source-control-github/src/client/gitHubMedia.ts @@ -1,44 +1,25 @@ +import type { SourceControlMediaUrl } from "@t3tools/source-control-core/client/definition"; + /** * Only hosts where a credential is what decides the answer belong here. `objects.githubusercontent.com` * and the `private-user-images` links GitHub's own HTML carries are already signed and load on their * own, and a token does nothing for them once that signature expires. */ - +const WEB_HOSTS = new Set(["github.com", "www.github.com"]); const RAW_HOST = "raw.githubusercontent.com"; const LFS_HOST = "media.githubusercontent.com"; +export const GITHUB_CREDENTIALED_MEDIA_HOSTS: ReadonlySet = new Set([ + ...WEB_HOSTS, + RAW_HOST, + LFS_HOST, +]); + const ATTACHMENT_PATH_PATTERN = /^\/user-attachments\/assets\/[\w-]+$/u; const LEGACY_ATTACHMENT_PATH_PATTERN = /^\/[^/]+\/[^/]+\/assets\/\d+\/[\w-]+$/u; /** `blob` and `raw` both address file bytes; `raw` is the one the token is honoured on. */ const REPOSITORY_FILE_PATTERN = /^\/([^/]+)\/([^/]+)\/(?:raw|blob)\/(.*[^/])$/u; -function canonicalUrl(host: string, url: URL): string { - return `https://${host}${url.pathname}${url.search}`; -} - -export function githubMediaFetchUrl(source: string): string | null { - let url: URL; - try { - url = new URL(source); - } catch { - return null; - } - if (url.protocol !== "https:") return null; - const host = url.hostname.toLowerCase(); - if (host === RAW_HOST || host === LFS_HOST) return canonicalUrl(host, url); - if (host !== "github.com" && host !== "www.github.com") return null; - if ( - ATTACHMENT_PATH_PATTERN.test(url.pathname) || - LEGACY_ATTACHMENT_PATH_PATTERN.test(url.pathname) - ) { - return `https://github.com${url.pathname}`; - } - const repositoryFile = REPOSITORY_FILE_PATTERN.exec(url.pathname); - return repositoryFile - ? `https://${RAW_HOST}/${repositoryFile[1]}/${repositoryFile[2]}/${repositoryFile[3]}` - : null; -} - -export function githubMediaFileName(fetchUrl: string): string { +function fileName(fetchUrl: string): string { const segment = new URL(fetchUrl).pathname.split("/").pop() ?? ""; let decoded: string; try { @@ -49,3 +30,27 @@ export function githubMediaFileName(fetchUrl: string): string { const name = decoded.replace(/[\p{Cc}\\/]/gu, ""); return name.length > 0 ? name : "github-media"; } + +function media(fetchUrl: string, kind?: "video"): SourceControlMediaUrl { + return { fetchUrl, fileName: fileName(fetchUrl), ...(kind ? { kind } : {}) }; +} + +export function gitHubMediaUrl(url: URL): SourceControlMediaUrl | null { + if (url.protocol !== "https:") return null; + const host = url.hostname.toLowerCase(); + if (host === RAW_HOST || host === LFS_HOST) { + return media(`https://${host}${url.pathname}${url.search}`); + } + if (!WEB_HOSTS.has(host)) return null; + // GitHub links an uploaded video bare, with no extension, and an image as markdown. + if (ATTACHMENT_PATH_PATTERN.test(url.pathname)) { + return media(`https://github.com${url.pathname}`, "video"); + } + if (LEGACY_ATTACHMENT_PATH_PATTERN.test(url.pathname)) { + return media(`https://github.com${url.pathname}`); + } + const repositoryFile = REPOSITORY_FILE_PATTERN.exec(url.pathname); + return repositoryFile + ? media(`https://${RAW_HOST}/${repositoryFile[1]}/${repositoryFile[2]}/${repositoryFile[3]}`) + : null; +} diff --git a/packages/source-control-github/src/server/GitHubRest.ts b/packages/source-control-github/src/server/GitHubRest.ts index 6ed65eebabf7..a29505ecc7af 100644 --- a/packages/source-control-github/src/server/GitHubRest.ts +++ b/packages/source-control-github/src/server/GitHubRest.ts @@ -232,12 +232,11 @@ export const layer = Layer.effect(GitHubRest, make).pipe( ); /** - * REST and the request policy, with the credential, quota reserve and rate-limit pause the policy - * reads through: everything both transports need. An application builds this once so every GitHub - * reader shares one view of each quota per host. + * REST and the request policy, with the credential and quota reserve the policy reads through. + * Built once, so every GitHub reader shares one view of each quota per host. The rate-limit pause + * comes from the server, which every host's change request reads share. */ export const layerWithDependencies = layer.pipe( Layer.provideMerge(GitHubCredentials.layer), Layer.provideMerge(GitHubQuota.layer), - Layer.provideMerge(SourceControlRateLimit.layer), ); diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index d5247732936f..2afcd0898c01 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -1,17 +1,34 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { definition } from "../client/definition.ts"; +import * as GitHubMediaFetch from "./gitHubMediaFetch.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; +import * as GitHubRest from "./GitHubRest.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +// `GitHubRest.layerWithDependencies` carries the quota reserve every GitHub reader shares, so it +// must be built once; the driver is. +const services = GitHubPullRequestApi.layer.pipe( + Layer.provideMerge(GitHubRest.layerWithDependencies), +); + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: GitHubSourceControlProvider.make, + discovery: GitHubSourceControlProvider.makeDiscovery, + pullRequests: GitHubPullRequestProvider.make, + fetchMedia: GitHubMediaFetch.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: GitHubSourceControlProvider.make, - discovery: GitHubSourceControlProvider.makeDiscovery, - pullRequests: GitHubPullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); diff --git a/packages/source-control-github/src/server/gitHubMediaFetch.test.ts b/packages/source-control-github/src/server/gitHubMediaFetch.test.ts new file mode 100644 index 000000000000..07e2bbd8f724 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubMediaFetch.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubMediaFetch from "./gitHubMediaFetch.ts"; + +describe("GitHubMediaFetch", () => { + it.effect("loads private media immediately after login with the GitHub credential", () => { + let lookups = 0; + const authorizations: Array = []; + return Effect.gen(function* () { + const fetchMedia = yield* GitHubMediaFetch.make; + const request = { + url: "https://raw.githubusercontent.com/owner/repo/main/shot.png", + headers: {}, + }; + expect((yield* fetchMedia(request))?.status).toBe(404); + expect((yield* fetchMedia(request))?.status).toBe(200); + expect((yield* fetchMedia(request))?.status).toBe(200); + expect(lookups).toBe(3); + expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]); + }).pipe( + Effect.provide( + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + ++lookups === 1 + ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })) + : Effect.succeed({ + host, + token: Redacted.make("signed-in"), + source: "gh" as const, + fingerprint: "fingerprint", + }), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + authorizations.push(request.headers.authorization); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + status: request.headers.authorization ? 200 : 404, + headers: { "content-type": "image/png" }, + }), + ), + ); + }), + ), + Effect.scoped, + ); + }); + + it.effect("keeps the credential off the object store a redirect hands the media to", () => { + const authorizations: Array<[string, string | undefined]> = []; + return Effect.gen(function* () { + const fetchMedia = yield* GitHubMediaFetch.make; + const response = yield* fetchMedia({ + url: "https://github.com/user-attachments/assets/1a1842fb", + headers: { range: "bytes=0-1" }, + }); + expect(response?.status).toBe(200); + expect(authorizations).toEqual([ + ["github.com", "Bearer signed-in"], + ["objects.example.com", undefined], + ]); + }).pipe( + Effect.provide( + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + Effect.succeed({ + host, + token: Redacted.make("signed-in"), + source: "gh" as const, + fingerprint: "fingerprint", + }), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = new URL(request.url); + authorizations.push([url.hostname, request.headers.authorization]); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + url.hostname === "github.com" + ? new Response(null, { + status: 302, + headers: { location: "https://objects.example.com/signed" }, + }) + : new Response(null, { status: 200 }), + ), + ); + }), + ), + Effect.scoped, + ); + }); +}); diff --git a/packages/source-control-github/src/server/gitHubMediaFetch.ts b/packages/source-control-github/src/server/gitHubMediaFetch.ts new file mode 100644 index 000000000000..a360f873ae1c --- /dev/null +++ b/packages/source-control-github/src/server/gitHubMediaFetch.ts @@ -0,0 +1,68 @@ +import type { SourceControlMediaRequest } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/http"; + +import { GITHUB_CREDENTIALED_MEDIA_HOSTS } from "../client/gitHubMedia.ts"; + +import * as GitHubCredentials from "./GitHubCredentials.ts"; + +/** + * Everything a redirect leads to — the presigned object stores GitHub hands assets off to — + * authorizes with its own signature, and some of them reject a request that also carries a + * bearer token. + */ +const isCredentialedHost = (url: string) => { + try { + return GITHUB_CREDENTIALED_MEDIA_HOSTS.has(new URL(url).hostname.toLowerCase()); + } catch { + return false; + } +}; + +const MAX_REDIRECTS = 3; +const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; + +export const make = Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); + + // Both media hosts are served by github.com's account, which is the host `gh` stores it under. + // Read on every fetch, so media loads as soon as the user signs in. + const token = Effect.suspend(() => credentials.get("github.com")).pipe( + Effect.map((credential) => credential.token), + Effect.orElseSucceed(() => null), + ); + + return Effect.fn("GitHubMediaFetch.fetchMedia")(function* (request: SourceControlMediaRequest) { + const bearer = yield* token; + let target = request.url; + for (let hop = 0; ; hop += 1) { + const authorization = + bearer !== null && isCredentialedHost(target) ? `Bearer ${Redacted.value(bearer)}` : null; + const response: HttpClientResponse.HttpClientResponse = yield* httpClient + .execute( + HttpClientRequest.get(target).pipe( + HttpClientRequest.setHeaders({ + ...request.headers, + // The bytes are streamed straight through, so never let an encoding layer in. + "accept-encoding": "identity", + ...(authorization === null ? {} : { authorization }), + }), + ), + ) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT)); + const location = response.headers.location; + if (response.status < 300 || response.status >= 400) return response; + if (!location || hop >= MAX_REDIRECTS) return null; + const next = new URL(location, target); + if (next.protocol !== "https:") return null; + target = next.toString(); + } + }); +}); diff --git a/packages/source-control-github/tsconfig.json b/packages/source-control-github/tsconfig.json index 374bac55202d..154996e94d31 100644 --- a/packages/source-control-github/tsconfig.json +++ b/packages/source-control-github/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["node"] }, - "include": ["src"] + "include": ["src", "scripts"] } diff --git a/packages/source-control-gitlab/src/server/driver.ts b/packages/source-control-gitlab/src/server/driver.ts index 175efc16269e..faf30e3406ae 100644 --- a/packages/source-control-gitlab/src/server/driver.ts +++ b/packages/source-control-gitlab/src/server/driver.ts @@ -1,17 +1,28 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { definition } from "../client/definition.ts"; +import * as GitLabCli from "./GitLabCli.ts"; +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; +const services = GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)); + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: GitLabSourceControlProvider.make, + discovery: Effect.succeed(GitLabSourceControlProvider.discovery), + pullRequests: GitLabPullRequestProvider.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: GitLabSourceControlProvider.make, - discovery: Effect.succeed(GitLabSourceControlProvider.discovery), - pullRequests: GitLabPullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); From 39f6e2b2a22678013ed691512e6703e99c7d0861 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:34 -0700 Subject: [PATCH 05/65] refactor(source-control): git and process behaviour comes from forge hooks (#23995) Co-authored-by: Claude Opus 5.5 --- apps/server/src/git/GitManager.test.ts | 141 +++--- apps/server/src/git/GitManager.ts | 427 ++++++++---------- .../PullRequestReviewGuideService.test.ts | 11 +- .../PullRequestReviewGuideService.ts | 7 +- apps/server/src/vcs/GitVcsDriver.ts | 28 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 14 +- apps/server/src/vcs/GitVcsDriverCore.ts | 110 +++-- apps/server/src/vcs/VcsProcess.test.ts | 31 +- apps/server/src/vcs/VcsProcess.ts | 64 +-- apps/server/src/vcs/VcsStatusBroadcaster.ts | 2 +- packages/contracts/src/vcs.ts | 10 +- packages/shared/src/sourceControl.test.ts | 11 - packages/shared/src/sourceControl.ts | 138 ------ .../src/server/AzureDevOpsCli.test.ts | 16 +- .../src/server/AzureDevOpsCli.ts | 14 + .../src/client/definition.ts | 5 + .../src/server/SourceControlHost.ts | 13 + .../src/server/SourceControlProvider.ts | 19 + .../src/client/definition.ts | 5 +- .../src/client/definition.ts | 1 + .../src/server/GitHubCredentials.ts | 7 + .../GitHubSourceControlProvider.test.ts | 28 ++ .../src/server/GitHubSourceControlProvider.ts | 54 ++- .../src/server/gitHubPullRequests.test.ts | 27 ++ .../src/client/definition.ts | 1 + .../src/server/GitLabCli.ts | 11 + 26 files changed, 630 insertions(+), 565 deletions(-) delete mode 100644 packages/shared/src/sourceControl.test.ts create mode 100644 packages/source-control-github/src/server/gitHubPullRequests.test.ts diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 660e2de9cbd8..d3ebc2daa1fe 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3053,6 +3053,47 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("hands the branch base to the host when a failed first push is retried", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "HEAD:main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/retried-push", "--track", "origin/main"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "retried.txt"), "retried\n"); + yield* runGit(repoDir, ["add", "retried.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Retried push"]); + const rejectOnce = NodePath.join(remoteDir, "reject-once"); + NodeFS.writeFileSync(rejectOnce, ""); + NodeFS.writeFileSync( + NodePath.join(remoteDir, "hooks", "pre-receive"), + `#!/bin/sh\nif [ -f "${rejectOnce}" ]; then rm "${rejectOnce}"; exit 1; fi\n`, + { mode: 0o755 }, + ); + + const recorded: Array<{ branch: string; baseBranch: string }> = []; + const { service } = createGitHubProviderWithFakeGh(); + const { manager } = yield* makeManager({ + sourceControlProvider: { + ...service, + recordBranchBase: ({ branch, baseBranch }) => + Effect.sync(() => { + recorded.push({ branch, baseBranch }); + }), + }, + }); + const failed = yield* runStackedAction(manager, { cwd: repoDir, action: "push" }).pipe( + Effect.flip, + ); + expect(failed).toBeDefined(); + const result = yield* runStackedAction(manager, { cwd: repoDir, action: "push" }); + + expect(result.push.status).toBe("pushed"); + expect(recorded).toEqual([{ branch: "feature/retried-push", baseBranch: "main" }]); + }), + ); + it.effect("create_pr targets the remote default branch when it is not main", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -4576,63 +4617,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "derived-fork.txt"), "derived fork\n"); - yield* runGit(repoDir, ["add", "derived-fork.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); - yield* runGit(repoDir, [ - "push", - "-u", - "binbandit-seed", - "fix/git-action-default-without-origin", - ]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 642, - title: "fix: use commit as the default git action without origin", - url: "https://github.com/pingdotgg/t3code/pull/642", - baseRefName: "main", - headRefName: "fix/git-action-default-without-origin", - state: "open", - isCrossRepository: true, - headRepositoryOwnerLogin: "binbandit", - }, - repositoryCloneUrls: { - "binbandit/t3code": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "642", - mode: "local", - }); - - expect(result.branch).toBe("fix/git-action-default-without-origin"); - expect(result.worktreePath).toBeNull(); - expect( - (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), - ).toBe("binbandit-seed/fix/git-action-default-without-origin"); - }), - ); - it.effect("refreshes a reused PR worktree onto the updated pull request head", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -5058,6 +5042,49 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("creates a merge request worktree from the host's own head ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/mr-ref-only"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "mr.txt"), "merge request\n"); + yield* runGit(repoDir, ["add", "mr.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Merge request ref only"]); + const head = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/merge-requests/93/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/mr-ref-only"]); + + const { service } = createGitHubProviderWithFakeGh({ + pullRequest: { + number: 93, + title: "Merge request ref only", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/93", + baseRefName: "main", + headRefName: "feature/mr-ref-only", + state: "open", + }, + }); + const { manager } = yield* makeManager({ + sourceControlProvider: { ...service, kind: SourceControlProviderKind.make("gitlab") }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "93", + mode: "worktree", + }); + + expect(result.isOnPullRequestHead).toBe(true); + expect( + (yield* runGit(result.worktreePath as string, ["rev-parse", "HEAD"])).stdout.trim(), + ).toBe(head); + }), + ); + it.effect("preserves an unrelated local branch and upstream that share the fork head name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 0e517a53ae9a..6902e84777ba 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,4 +1,3 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; @@ -54,16 +53,11 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { - getChangeRequestTerminologyForKind, - isSshRemoteUrl, type ChangeRequestTerminology, -} from "@t3tools/shared/sourceControl"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import { - parseGitHubRepositorySelector, - resolveGitHubRepository, -} from "@t3tools/source-control-github/server/gitHubRepositoryResolution"; + UNKNOWN_SOURCE_CONTROL_CLIENT, +} from "@t3tools/source-control-core/client/definition"; import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, @@ -255,48 +249,15 @@ interface BranchHeadContext { } export function pullRequestRepositoryKey(value: string): string | null { - try { - const url = new URL(value); - const match = - /^(.*)(?:\/pulls?\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( - url.pathname, - ); - if (match?.[1] === undefined) return null; - url.pathname = match[1]; - url.search = ""; - url.hash = ""; - return sourceControlClients.repositoryKeyFromRemote(url.toString()); - } catch { - return null; - } + const repositoryUrl = sourceControlClients.parseChangeRequestUrl(value)?.link.repositoryUrl; + return repositoryUrl === undefined + ? null + : sourceControlClients.repositoryKeyFromRemote(repositoryUrl); } -function parseRepositoryNameFromPullRequestUrl(url: string): string | null { - const trimmed = url.trim(); - const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); - const repositoryName = match?.[1]?.trim() ?? ""; - return repositoryName.length > 0 ? repositoryName : null; -} - -function resolveHeadRepositoryNameWithOwner( - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, -): string | null { - const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; - if (explicitRepository.length > 0) { - return explicitRepository; - } - - if (!pullRequest.isCrossRepository) { - return null; - } - - const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; - const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); - if (ownerLogin.length === 0 || !repositoryName) { - return null; - } - - return `${ownerLogin}/${repositoryName}`; +/** Null when the host did not name the repository the head lives in. */ +function resolveHeadRepositoryNameWithOwner(pullRequest: PullRequestHeadRemoteInfo): string | null { + return normalizeOptionalString(pullRequest.headRepositoryNameWithOwner); } function resolvePullRequestWorktreeLocalBranchName( @@ -331,7 +292,8 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null if (trimmed.length === 0) { return null; } - // GitLab reports the top-level group as owner. The full path distinguishes subgroups. + // The first segment only: a nested path's owner is its top-level group. The full path + // distinguishes subgroups. const [ownerLogin] = trimmed.split("/"); const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; @@ -352,27 +314,6 @@ function normalizeOptionalOwnerLogin(value: string | null | undefined): string | return normalized ? normalized.toLowerCase() : null; } -function resolvePullRequestHeadRepositoryNameWithOwner( - pr: PullRequestHeadRemoteInfo & { url: string }, -) { - const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); - if (explicitRepository) { - return explicitRepository; - } - - if (!pr.isCrossRepository) { - return null; - } - - const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); - const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); - if (!ownerLogin || !repositoryName) { - return null; - } - - return `${ownerLogin}/${repositoryName}`; -} - interface PullRequestHeadIdentity { readonly repositoryNameWithOwner: string | null; readonly ownerLogin: string | null; @@ -394,7 +335,7 @@ function resolveExpectedHeadIdentity( function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity { const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( - resolvePullRequestHeadRepositoryNameWithOwner(pr), + resolveHeadRepositoryNameWithOwner(pr), ); return { repositoryNameWithOwner, @@ -634,11 +575,6 @@ function headBranchProbe( ); } -/** A URL's host as `new URL` reads it: lowercased, any non-default port kept, `:443` dropped. */ -function urlHost(url: string): string | null { - return URL.canParse(url) ? new URL(url).host : null; -} - function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -726,9 +662,16 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const environment = yield* HostProcess.Environment; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + const changeRequestTermsFor = Effect.fn("GitManager.changeRequestTermsFor")(function* ( + cwd: string, + ) { + return yield* sourceControlProvider(cwd).pipe( + Effect.map((provider) => sourceControlClients.get(provider.kind).changeRequest), + Effect.orElseSucceed(() => UNKNOWN_SOURCE_CONTROL_CLIENT.changeRequest), + ); + }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; const threads = yield* ProjectionStore.ProjectionStoreV2; const projects = yield* ProjectStore.ProjectStoreV2; @@ -775,6 +718,31 @@ export const make = Effect.gen(function* () { ), ), ); + // Hands the base T3 recorded for a branch to the host, whose own tools may keep it elsewhere. + // Best-effort: nothing T3 does reads it back from there. + const shareBranchBase = Effect.fn("GitManager.shareBranchBase")(function* ( + cwd: string, + branch: string, + ) { + const baseBranch = yield* gitCore + .readBranchBase(cwd, branch) + .pipe(Effect.orElseSucceed(() => null)); + if (baseBranch === null) return; + const provider = yield* sourceControlProvider(cwd).pipe(Effect.orElseSucceed(() => null)); + yield* provider?.recordBranchBase?.({ cwd, branch, baseBranch }) ?? Effect.void; + }); + // Publishing a branch that tracks its base records that base first, even when the push then + // fails, so the host hears of it after every successful push rather than the first attempt. + const pushCurrentBranch = Effect.fn("GitManager.pushCurrentBranch")(function* ( + cwd: string, + branch: string | null, + ) { + const result = yield* gitCore.pushCurrentBranch(cwd, branch); + if (result.status === "pushed" && result.branch) { + yield* shareBranchBase(cwd, result.branch); + } + return result; + }); const readWorktreesDirectory = serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.worktreesDirectory), Effect.orElseSucceed(() => ""), @@ -790,7 +758,15 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => null), ); const worktreesDirectory = yield* readWorktreesDirectory; - return yield* gitCore.createWorktree(input, { worktreesDirectory, ...options, submodules }); + const result = yield* gitCore.createWorktree(input, { + worktreesDirectory, + ...options, + submodules, + }); + if (input.newRefName && input.baseRefName) { + yield* shareBranchBase(input.cwd, input.newRefName); + } + return result; }); const readRepositoryInstructions = Effect.fn("GitManager.readRepositoryInstructions")( @@ -990,6 +966,31 @@ export const make = Effect.gen(function* () { ), ); + // The kind comes from the provider, not the remote alone: an unrecognised remote can resolve + // to a known host. + const changeRequestHeadRef = Effect.fn("GitManager.changeRequestHeadRef")(function* ( + cwd: string, + number: number, + ) { + const provider = yield* sourceControlProvider(cwd); + const ref = sourceControlClients.get(provider.kind).changeRequestHeadRef?.(number); + if (ref === undefined) { + return yield* new GitManagerError({ + operation: "changeRequestHeadRef", + cwd, + detail: "This host publishes no ref for a change request's head.", + }); + } + return ref; + }); + + const fetchChangeRequestHeadIntoBranch = Effect.fn("GitManager.fetchChangeRequestHeadIntoBranch")( + function* (cwd: string, number: number, branch: string) { + const ref = yield* changeRequestHeadRef(cwd, number); + yield* gitCore.fetchRefIntoBranch({ cwd, ref, branch }); + }, + ); + const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( function* ( cwd: string, @@ -999,47 +1000,38 @@ export const make = Effect.gen(function* () { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { - yield* gitCore - .fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }) - .pipe( - // Azure DevOps, GitLab and Bitbucket publish no `refs/pull//head`. A head in the - // same repository is a branch on the primary remote, so it is fetched by name instead, - // but only when that remote is the pull request's own repository: Azure finds a pull - // request by number anywhere in the organization. Only while it is open, too: the - // branch of a closed one may have moved on past the head it was closed with. - Effect.catch((cause) => - pullRequest.isCrossRepository === true || pullRequest.state !== "open" - ? Effect.fail(cause) - : Effect.gen(function* () { - const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); - const remoteUrl = yield* gitCore.readConfigValue( - cwd, - `remote.${remoteName}.url`, - ); - const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); - if ( - remoteUrl === null || - pullRequestKey === null || - sourceControlClients.canonicalRepositoryKey(pullRequestKey) !== - sourceControlClients.canonicalRepositoryKey( - sourceControlClients.repositoryKeyFromRemote(remoteUrl), - ) - ) { - return yield* cause; - } - yield* gitCore.fetchRemoteBranch({ - cwd, - remoteName, - remoteBranch: pullRequest.headBranch, - localBranch, - }); - }), - ), - ); + yield* fetchChangeRequestHeadIntoBranch(cwd, pullRequest.number, localBranch).pipe( + // Without the host's head ref, a head in the same repository is a branch on the + // primary remote, so it is fetched by name instead, but only when that remote is the + // pull request's own repository: a host can find a change request by number outside + // the checkout's repository. Only while it is open, too: the branch of a closed one + // may have moved on past the head it was closed with. + Effect.catch((cause) => + pullRequest.isCrossRepository === true || pullRequest.state !== "open" + ? Effect.fail(cause) + : Effect.gen(function* () { + const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + const remoteUrl = yield* gitCore.readConfigValue(cwd, `remote.${remoteName}.url`); + const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); + if ( + remoteUrl === null || + pullRequestKey === null || + sourceControlClients.canonicalRepositoryKey(pullRequestKey) !== + sourceControlClients.canonicalRepositoryKey( + sourceControlClients.repositoryKeyFromRemote(remoteUrl), + ) + ) { + return yield* cause; + } + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + }), + ), + ); return; } @@ -1081,29 +1073,23 @@ export const make = Effect.gen(function* () { ) => materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( Effect.catch((primaryCause) => - gitCore - .fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }) - .pipe( - Effect.mapError( - (fallbackCause) => - new GitPullRequestMaterializationError({ - cwd, - pullRequestNumber: pullRequest.number, - headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), - headBranch: pullRequest.headBranch, - localBranch, - cause: new AggregateError( - [primaryCause, fallbackCause], - `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, - { cause: primaryCause }, - ), - }), - ), + fetchChangeRequestHeadIntoBranch(cwd, pullRequest.number, localBranch).pipe( + Effect.mapError( + (fallbackCause) => + new GitPullRequestMaterializationError({ + cwd, + pullRequestNumber: pullRequest.number, + headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), + headBranch: pullRequest.headBranch, + localBranch, + cause: new AggregateError( + [primaryCause, fallbackCause], + `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, + { cause: primaryCause }, + ), + }), ), + ), ), ); const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; @@ -1437,39 +1423,27 @@ export const make = Effect.gen(function* () { const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); - // Whether the GitHub provider creates PRs in the only remote. It picks GH_REPO when that - // parses, else what resolveGitHubRepository makes of the remote and its gh-resolved mark from - // `gh repo set-default`, given the host the provider's context gets from the remote URL. An - // HTTPS remote keeps a non-default port there, and the provider then ignores the mark. With no - // answer at all, the provider falls back to the remote itself. + // Whether the remote's host creates change requests in the only remote. A host's tools can let + // configuration outside the remotes name another repository; the remote itself otherwise. const targetsOnlyRemote = Effect.fn("targetsOnlyRemote")(function* ( cwd: string, remoteName: string, remote: { readonly remoteUrl: string | null; readonly repositoryNameWithOwner: string | null }, ) { - const baseUrl = - remote.remoteUrl === null - ? undefined - : sourceControlClients.recognizeRemote(remote.remoteUrl)?.baseUrl; - const host = baseUrl === undefined ? null : urlHost(baseUrl); - const repository = remote.repositoryNameWithOwner?.toLowerCase(); - const fromEnvironment = parseGitHubRepositorySelector(environment.GH_REPO ?? "", host ?? ""); - if (fromEnvironment !== null) { - const { host: targetHost, owner, name } = fromEnvironment; - return ( - host !== null && - urlHost(`https://${targetHost}`) === host && - `${owner}/${name}`.toLowerCase() === repository - ); - } - const mark = yield* readConfigValueNullable(cwd, `remote.${remoteName}.gh-resolved`); - const { locator } = resolveGitHubRepository({ - remotes: remote.remoteUrl === null ? "" : `${remoteName}\t${remote.remoteUrl} (fetch)`, - resolved: mark === null ? "" : `remote.${remoteName}.gh-resolved ${mark}`, - hostHint: host ?? undefined, - defaultHost: host ?? "github.com", + const provider = + remote.remoteUrl === null ? null : sourceControlClients.recognizeRemote(remote.remoteUrl); + if (remote.remoteUrl === null || provider === null) return true; + const context = { provider, remoteName, remoteUrl: remote.remoteUrl }; + const handle = yield* sourceControlProviders + .resolveHandle({ cwd, context }) + .pipe(Effect.orElseSucceed(() => null)); + const createsChangeRequestsIn = handle?.provider.createsChangeRequestsIn; + if (createsChangeRequestsIn === undefined) return true; + return yield* createsChangeRequestsIn({ + cwd, + context: handle?.context ?? context, + repository: remote.repositoryNameWithOwner, }); - return locator === null || `${locator.owner}/${locator.name}`.toLowerCase() === repository; }); const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( @@ -1564,9 +1538,9 @@ export const make = Effect.gen(function* () { remoteName, ); - // With no origin to compare against, the only remote is the repository gh targets - // unless GH_REPO or `gh repo set-default` (gh-resolved) names another, so a branch on it - // is not a fork. With several remotes, or with that other target, it may still be a fork. + // With no origin to compare against, the only remote is the repository change requests + // are created in unless the host's own configuration names another, so a branch on it is + // not a fork. With several remotes, or with that other target, it may still be a fork. const isOnlyRemote = remoteName !== null && remoteName !== "origin" && @@ -1928,12 +1902,7 @@ export const make = Effect.gen(function* () { cwd: string, result: Pick, ) { - const terms = yield* sourceControlProvider(cwd).pipe( - Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.orElseSucceed(() => - getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")), - ), - ); + const terms = yield* changeRequestTermsFor(cwd); const summary = summarizeGitActionResult(result, terms); let latestOpenPr: PullRequestInfo | null = null; let currentBranchIsDefault = false; @@ -2032,7 +2001,7 @@ export const make = Effect.gen(function* () { if (configuredBranch) return { baseBranch: configuredBranch, baseRef: configuredRef }; } - const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); + const configured = yield* gitCore.readBranchBase(cwd, branch); if (configured) { const localRef = configured.startsWith("refs/") ? configured : `refs/heads/${configured}`; const localBranch = yield* gitCore.resolveBranchName(cwd, localRef); @@ -2278,7 +2247,7 @@ export const make = Effect.gen(function* () { emit: GitActionProgressEmitter, ) { const provider = yield* sourceControlProvider(cwd); - const terms = getChangeRequestTerminologyForKind(provider.kind); + const terms = sourceControlClients.get(provider.kind).changeRequest; const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { @@ -2871,53 +2840,52 @@ export const make = Effect.gen(function* () { yield* ensureExistingWorktreeUpstream(worktreePath); - const refreshed = yield* gitCore - // The pull request's own ref, because it is the only thing that certainly names its - // head. The branch's upstream does not: configuring it is best-effort, so a branch cut - // from `origin/main` whose head branch has since been deleted still resolves — and - // following it would move the checkout onto main and call that the pull request. - .fetchPullRequestHeadCommit({ cwd: worktreePath, prNumber: pullRequest.number }) - .pipe( - // A host that publishes no `refs/pull//head` leaves the remote-tracking branch, - // taken only where it is the head branch's own rather than whatever the checkout - // happened to be cut from. - Effect.catch(() => - Effect.gen(function* () { - const details = yield* gitCore.statusDetails(worktreePath); - if ( - details.upstreamRef === null || - !details.upstreamRef.endsWith(`/${pullRequest.headBranch}`) - ) { - return yield* new GitManagerError({ - operation: "preparePullRequestThread", - cwd: worktreePath, - detail: "The pull request head could not be resolved for this checkout.", - }); - } - return yield* gitCore.resolveCommit({ + // The pull request's own ref, because it is the only thing that certainly names its + // head. The branch's upstream does not: configuring it is best-effort, so a branch cut + // from `origin/main` whose head branch has since been deleted still resolves — and + // following it would move the checkout onto main and call that the pull request. + const refreshed = yield* changeRequestHeadRef(worktreePath, pullRequest.number).pipe( + Effect.flatMap((ref) => gitCore.fetchRefCommit({ cwd: worktreePath, ref })), + // A host that publishes no head ref leaves the remote-tracking branch, + // taken only where it is the head branch's own rather than whatever the checkout + // happened to be cut from. + Effect.catch(() => + Effect.gen(function* () { + const details = yield* gitCore.statusDetails(worktreePath); + if ( + details.upstreamRef === null || + !details.upstreamRef.endsWith(`/${pullRequest.headBranch}`) + ) { + return yield* new GitManagerError({ + operation: "preparePullRequestThread", cwd: worktreePath, - revision: details.upstreamRef, + detail: "The pull request head could not be resolved for this checkout.", }); - }), - ), - Effect.flatMap((target) => - gitCore.refreshCheckedOutBranch({ + } + return yield* gitCore.resolveCommit({ cwd: worktreePath, - targetCommit: target.commitSha, - resetWhenHeadCommit: upstreamCommitBeforeFetch, - }), - ), - Effect.catch((error) => - Effect.logWarning( - "GitManager.preparePullRequestThread reused worktree refresh failed", - { - worktreePath, - localBranch: localPullRequestBranch, - cause: error, - }, - ).pipe(Effect.as({ moved: false, onTarget: false })), - ), - ); + revision: details.upstreamRef, + }); + }), + ), + Effect.flatMap((target) => + gitCore.refreshCheckedOutBranch({ + cwd: worktreePath, + targetCommit: target.commitSha, + resetWhenHeadCommit: upstreamCommitBeforeFetch, + }), + ), + Effect.catch((error) => + Effect.logWarning( + "GitManager.preparePullRequestThread reused worktree refresh failed", + { + worktreePath, + localBranch: localPullRequestBranch, + cause: error, + }, + ).pipe(Effect.as({ moved: false, onTarget: false })), + ), + ); // Only when the checkout actually moved: another thread may be running in this worktree, // and re-running the setup script under it buys nothing when the code did not change. @@ -3189,14 +3157,7 @@ export const make = Effect.gen(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; - const changeRequestTerms = wantsPr - ? yield* sourceControlProvider(input.cwd).pipe( - Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.orElseSucceed(() => - getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")), - ), - ) - : null; + const changeRequestTerms = wantsPr ? yield* changeRequestTermsFor(input.cwd) : null; const commit = commitAction ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( @@ -3225,7 +3186,7 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), - Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), + Effect.flatMap(() => pushCurrentBranch(input.cwd, currentBranch)), ) : { status: "skipped_not_requested" as const }; diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index 76f703123c27..b4dd965570a9 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -94,6 +94,7 @@ const makeRepositories = Effect.gen(function* () { const detail = (workspaceRoot: string, headSha: string): PullRequestDetail => ({ + provider: "github", number: 7, title: "Add the feature", body: "Adds the feature.", @@ -385,13 +386,7 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic name: "a head no source has", reportedHead: () => "e".repeat(40), shadow: "none", - expected: [ - "local objects:", - "the commit:", - "branch feature:", - "refs/pull/7/head:", - "refs/merge-requests/7/head:", - ], + expected: ["local objects:", "the commit:", "branch feature:", "refs/pull/7/head:"], }, ])("refuses to write a guide for $name", ({ reportedHead, shadow, expected }) => Effect.scoped( @@ -415,6 +410,8 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic assert.strictEqual(result.run?._tag, "Failed"); const detail = result.run?._tag === "Failed" ? result.run.detail : ""; for (const part of expected) assert.include(detail, part); + // Only the host's own head ref is tried. + assert.notInclude(detail, "refs/merge-requests/"); assert.deepStrictEqual(run.observed, []); assert.isFalse(run.fetchedRefs.some((ref) => ref.startsWith("-"))); const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index e78d850d6834..5ae93c8827b1 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -251,6 +251,7 @@ const make = Effect.gen(function* () { ), ); const fetchAndResolve = (ref: string) => fetch([ref]).pipe(Effect.andThen(resolveHead)); + const headRef = sourceControlClients.get(detail.provider).changeRequestHeadRef?.(number); const branchRef = `refs/heads/${branch}`; const checkBranch = git.execute({ operation: "PullRequestReviewGuideService.checkHeadBranch", @@ -266,11 +267,7 @@ const make = Effect.gen(function* () { source: `branch ${branch}`, head: checkBranch.pipe(Effect.andThen(fetchAndResolve(branchRef))), }, - { source: `refs/pull/${number}/head`, head: fetchAndResolve(`refs/pull/${number}/head`) }, - { - source: `refs/merge-requests/${number}/head`, - head: fetchAndResolve(`refs/merge-requests/${number}/head`), - }, + ...(headRef === undefined ? [] : [{ source: headRef, head: fetchAndResolve(headRef) }]), ]; const failures: Array = []; for (const { source, head } of sources) { diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 3f839643086b..0599b0caf24b 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -201,15 +201,10 @@ export interface GitRenameBranchResult { branch: string; } -export interface GitFetchPullRequestBranchInput { +export interface GitFetchRefInput { cwd: string; - prNumber: number; - branch: string; -} - -export interface GitFetchPullRequestHeadCommitInput { - cwd: string; - prNumber: number; + /** A ref on the primary remote, such as a host's change request head ref. */ + ref: string; } export interface GitResolveCommitInput { @@ -342,6 +337,14 @@ export class GitVcsDriver extends Context.Service< cwd: string, key: string, ) => Effect.Effect; + /** + * The branch, named as on the host, that T3 recorded `branch` was cut from. Null when none + * was recorded. + */ + readonly readBranchBase: ( + cwd: string, + branch: string, + ) => Effect.Effect; readonly resolveBranchName: ( cwd: string, refName: string, @@ -354,11 +357,12 @@ export class GitVcsDriver extends Context.Service< input: VcsCreateWorktreeInput, options?: CreateWorktreeOptions, ) => Effect.Effect; - readonly fetchPullRequestBranch: ( - input: GitFetchPullRequestBranchInput, + /** Force-updates local `branch` to the primary remote's `ref`. */ + readonly fetchRefIntoBranch: ( + input: GitFetchRefInput & { readonly branch: string }, ) => Effect.Effect; - readonly fetchPullRequestHeadCommit: ( - input: GitFetchPullRequestHeadCommitInput, + readonly fetchRefCommit: ( + input: GitFetchRefInput, ) => Effect.Effect; readonly resolveCommit: ( input: GitResolveCommitInput, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 6bd0137154da..fc0ee47223fe 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3066,7 +3066,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["rev-parse", "HEAD"]), ); assert.equal( - yield* git(cwd, ["config", "branch.feature/parallel.gh-merge-base"]), + yield* git(cwd, ["config", "branch.feature/parallel.t3-merge-base"]), initialBranch, ); for (const [configured, expected] of [ @@ -3117,7 +3117,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), commit); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/revision.gh-merge-base"), + yield* driver.readConfigValue(cwd, "branch.feature/revision.t3-merge-base"), null, ); }), @@ -3142,7 +3142,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/branch.gh-merge-base"), + yield* driver.readConfigValue(cwd, "branch.feature/branch.t3-merge-base"), baseBranch, ); }), @@ -3465,7 +3465,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "# test\n", ); assert.equal( - yield* driver.readConfigValue(worktreePath, "branch.feature/locked-config.gh-merge-base"), + yield* driver.readConfigValue(worktreePath, "branch.feature/locked-config.t3-merge-base"), null, ); assert.equal(yield* fileSystem.exists(pathService.join(cwd, ".git/config.lock")), true); @@ -3737,7 +3737,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* driver.resolvePrimaryRemoteName(cwd), remoteName); yield* driver.fetchRemoteTrackingBranch({ cwd, remoteName, remoteBranch: initialBranch }); yield* driver.fetchRemote({ cwd, remoteName }); - const head = yield* driver.fetchPullRequestHeadCommit({ cwd, prNumber: 7 }); + const head = yield* driver.fetchRefCommit({ cwd, ref: "refs/pull/7/head" }); assert.isFalse(yield* fileSystem.exists((yield* Path.Path).join(cwd, "t3-marker"))); assert.equal(head.commitSha, yield* git(cwd, ["rev-parse", "HEAD"])); @@ -4133,7 +4133,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.strictEqual(status.branchChanges?.deletions, 0); // gh pr create consumes this setting verbatim as the hosted target branch. assert.equal( - yield* driver.readConfigValue(worktreePath, "branch.feature/from-upstream.gh-merge-base"), + yield* driver.readConfigValue(worktreePath, "branch.feature/from-upstream.t3-merge-base"), "release", ); assert.equal(changes.baseRef, "upstream/release"); @@ -4362,7 +4362,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), "origin/feature/x", ); - assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev"); + assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.t3-merge-base"), "dev"); const preview = yield* driver.getReviewDiffPreview({ cwd }); const changes = preview.sources.find((source) => source.kind === "branch-range")!; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2bc11cdb0451..78c88d3c790b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -53,6 +53,13 @@ import * as ServerConfig from "../config.ts"; import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +/** T3's record of the branch, named as on the host, that a branch was cut from. */ +const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base`; +/** + * Where T3 kept that record before it had a key of its own, for every host alike: the GitHub + * CLI's key, which branches cut before then still carry instead. + */ +const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; const gitProcesses = Semaphore.makeUnsafe(8); const WORKTREE_ADD_TIMEOUT_MS = 300_000; const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); @@ -1818,8 +1825,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - const configuredBaseBranch = - (yield* readConfigValue(cwd, `branch.${refName}.gh-merge-base`)) ?? ""; + const configuredBaseBranch = (yield* readBranchBase(cwd, refName)) ?? ""; const configuredBase = yield* resolveComparisonBaseRef(cwd, configuredBaseBranch); if ( configuredBase !== null && @@ -2638,16 +2644,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const remoteName = publishRemoteName ?? currentUpstream.remoteName; const publishBranch = yield* resolvePublishBranchName(cwd, branch); // Publishing retargets the upstream, so preserve its comparison base first. - const configuredMergeBase = yield* runGitStdout( - "GitVcsDriver.pushCurrentBranch.readMergeBase", - cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (configuredMergeBase.length === 0) { + if ((yield* readBranchBase(cwd, branch)) === null) { yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [ "config", - `branch.${branch}.gh-merge-base`, + branchBaseConfigKey(branch), currentUpstream.branchName, ]); if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { @@ -3369,6 +3369,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); + const readBranchBase: GitVcsDriver.GitVcsDriver["Service"]["readBranchBase"] = Effect.fn( + "GitVcsDriver.readBranchBase", + )(function* (cwd, branch) { + const base = yield* readConfigValue(cwd, branchBaseConfigKey(branch)); + return base ?? (yield* readConfigValue(cwd, legacyBranchBaseConfigKey(branch))); + }); + const resolveBranchBase = Effect.fn("GitVcsDriver.resolveBranchBase")(function* ( cwd: string, refName: string, @@ -3842,17 +3849,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - if (input.newRefName && input.baseRefName) { + const newRefName = input.newRefName; + if (newRefName && input.baseRefName) { const base = yield* resolveBranchBase(input.cwd, input.baseRefName); if (base) { yield* Effect.gen(function* () { yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ "config", - `branch.${input.newRefName}.gh-merge-base`, + branchBaseConfigKey(newRefName), base.branchName, ]); if (base.baseRef.startsWith("refs/remotes/")) { - // gh consumes its key as a provider branch; T3 also needs the selected remote. + // The base key names a branch on the host; this one keeps the remote it came from. yield* runGit("GitVcsDriver.createWorktree.configureComparisonRef", input.cwd, [ "config", `branch.${input.newRefName}.t3-merge-base-ref`, @@ -3878,25 +3886,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = - Effect.fn("fetchPullRequestBranch")(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - yield* executeGit( - "GitVcsDriver.fetchPullRequestBranch", - input.cwd, - [ - "fetch", - "--quiet", - "--no-tags", - "--", - remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, - ], - { - fallbackErrorDetail: "git fetch pull request branch failed", - }, - ); - }); + const fetchRefIntoBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRefIntoBranch"] = Effect.fn( + "GitVcsDriver.fetchRefIntoBranch", + )(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitVcsDriver.fetchRefIntoBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + "--", + remoteName, + `+${input.ref}:refs/heads/${input.branch}`, + ], + { + fallbackErrorDetail: "git fetch ref into branch failed", + }, + ); + }); const resolveCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveCommit"] = Effect.fn( "resolveCommit", @@ -3910,22 +3919,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha }; }); - const fetchPullRequestHeadCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestHeadCommit"] = - Effect.fn("fetchPullRequestHeadCommit")(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - // No refspec destination: the pull head lands in FETCH_HEAD (per worktree) instead of a - // branch, which is the only way to read it while that branch is checked out somewhere. - yield* executeGit( - "GitVcsDriver.fetchPullRequestHeadCommit", - input.cwd, - ["fetch", "--quiet", "--no-tags", "--", remoteName, `refs/pull/${input.prNumber}/head`], - { - fallbackErrorDetail: "git fetch pull request head failed", - }, - ); + const fetchRefCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchRefCommit"] = Effect.fn( + "GitVcsDriver.fetchRefCommit", + )(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + // No refspec destination: the ref lands in FETCH_HEAD (per worktree) instead of a branch, + // which is the only way to read it while that branch is checked out somewhere. + yield* executeGit( + "GitVcsDriver.fetchRefCommit", + input.cwd, + ["fetch", "--quiet", "--no-tags", "--", remoteName, input.ref], + { + fallbackErrorDetail: "git fetch ref failed", + }, + ); - return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); - }); + return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); + }); const refreshCheckedOutBranch: GitVcsDriver.GitVcsDriver["Service"]["refreshCheckedOutBranch"] = Effect.fn("refreshCheckedOutBranch")(function* (input) { @@ -4507,13 +4517,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffPreview, getReviewDiffFileContents, readConfigValue, + readBranchBase, resolveBranchName, listRefs, createWorktree: (input, options) => withListRefsInvalidation(input.cwd, createWorktree(input, options)), - fetchPullRequestBranch: (input) => - withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), - fetchPullRequestHeadCommit, + fetchRefIntoBranch: (input) => withListRefsInvalidation(input.cwd, fetchRefIntoBranch(input)), + fetchRefCommit, resolveCommit, refreshCheckedOutBranch: (input) => withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 03d274fdd823..212415171bdc 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -258,7 +258,7 @@ describe("VcsProcess.run", () => { }), ); - it.effect("bounds a synthetic burst of GitHub API processes", () => + it.effect("bounds a burst of processes that share a concurrency key", () => Effect.gen(function* () { const gate = yield* Deferred.make(); const starts = yield* Queue.unbounded(); @@ -294,10 +294,11 @@ describe("VcsProcess.run", () => { const burst = yield* Effect.all( Array.from({ length: 32 }, (_, index) => service.run({ - operation: `synthetic.github.${index}`, - command: "gh", + operation: `synthetic.cli.${index}`, + command: "host-cli", args: ["api", "user"], cwd: "/workspace", + concurrency: { key: "host-cli", limit: 4 }, }), ), { concurrency: "unbounded" }, @@ -391,6 +392,30 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("reads a failure with the command's own classifier and names it in its terms", () => + Effect.gen(function* () { + const fail = (input: Partial) => + run({ + operation: "test.classify", + command: "node", + args: ["-e", "process.stderr.write('HTTP 404: 404 Not Found'); process.exit(1)"], + cwd: process.cwd(), + ...input, + }).pipe(Effect.flip); + + expect(yield* fail({})).toMatchObject({ + failureKind: "command-failed", + detail: "Process exited with a non-zero status.", + }); + expect( + yield* fail({ + classifyFailure: (stderr) => (stderr.includes("404") ? "not-found" : null), + notFoundDetail: "Merge request not found.", + }), + ).toMatchObject({ failureKind: "not-found", detail: "Merge request not found." }); + }).pipe(provideLive), + ); + it.effect("retains spawn causes without exposing process arguments in the error message", () => Effect.gen(function* () { const secretArgument = "--token=super-secret-token"; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 2a62c6fb1ac4..980f4c7e674b 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -20,6 +20,7 @@ import { VcsProcessTimeoutError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { SourceControlProcessInput } from "@t3tools/source-control-core/server/SourceControlHost"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -38,6 +39,9 @@ export interface VcsProcessInput { readonly maxOutputBytes?: number; readonly outputMode?: ProcessRunner.ProcessRunInput["outputMode"]; readonly appendTruncationMarker?: boolean; + readonly classifyFailure?: SourceControlProcessInput["classifyFailure"]; + readonly notFoundDetail?: string; + readonly concurrency?: SourceControlProcessInput["concurrency"]; } export interface VcsProcessOutput { @@ -61,7 +65,6 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const VCS_PROCESS_CONCURRENCY = 8; -const GITHUB_PROCESS_CONCURRENCY = 4; /** Shown to users when a Git command fails because another process holds the index lock. */ export const GIT_INDEX_LOCKED_DETAIL = @@ -132,22 +135,23 @@ export const gitConfigEnv = ( * Git otherwise opens any folder holding `HEAD`, `objects/` and `refs/` as a * bare repository and applies its `config`, including commands such as * `core.fsmonitor`. Files saved in a project or checked out from a clone can - * form one, so git started by T3 (or by gh and friends) only opens a bare + * form one, so git started by T3 (or by a host CLI) only opens a bare * repository named with `--git-dir` or `GIT_DIR`. Git before 2.38 ignores it. */ export const explicitBareRepositoryEnv = (env: NodeJS.ProcessEnv, platform: NodeJS.Platform) => gitConfigEnv(env, "safe.bareRepository", "explicit", platform); -const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { +const classifyNonZeroExit = ( + stderr: string, + classifyFailure: VcsProcessInput["classifyFailure"], +): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); + const commandKind = classifyFailure?.(stderr) ?? null; if ( + commandKind === "authentication" || normalized.includes("authentication failed") || normalized.includes("not logged in") || - normalized.includes("gh auth login") || - normalized.includes("glab auth login") || - normalized.includes("az devops login") || - normalized.includes("please run az login") || normalized.includes("no oauth token") || normalized.includes("unauthorized") ) { @@ -155,6 +159,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai } if ( + commandKind === "rate-limited" || normalized.includes("api rate limit") || normalized.includes("rate limit exceeded") || normalized.includes("secondary rate limit") || @@ -164,24 +169,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "rate-limited"; } - if ( - (command === "gh" && - (normalized.includes("could not resolve to a pullrequest") || - normalized.includes("repository.pullrequest") || - normalized.includes("no pull requests found for branch") || - normalized.includes("pull request not found"))) || - (command === "glab" && - (normalized.includes("merge request not found") || - normalized.includes("not found") || - normalized.includes("404"))) || - (command === "az" && - normalized.includes("pull request") && - (normalized.includes("not found") || normalized.includes("does not exist"))) - ) { - return "not-found"; - } - - return "command-failed"; + return commandKind ?? "command-failed"; }; // Classify before discarding stderr; keep paths and process output out of errors. @@ -204,7 +192,15 @@ export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcess.Platform; const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); - const githubProcesses = yield* Semaphore.make(GITHUB_PROCESS_CONCURRENCY); + const keyedProcesses = new Map(); + const keyedPermit = (concurrency: NonNullable) => { + let semaphore = keyedProcesses.get(concurrency.key); + if (semaphore === undefined) { + semaphore = Semaphore.makeUnsafe(concurrency.limit); + keyedProcesses.set(concurrency.key, semaphore); + } + return semaphore.withPermits(1); + }; const spawn = Effect.fn("VcsProcess.spawn")(function* ( input: VcsProcessInput, @@ -315,7 +311,7 @@ export const make = Effect.gen(function* () { } if (!input.allowNonZeroExit && result.code !== 0) { - const failureKind = classifyNonZeroExit(input.command, result.stderr); + const failureKind = classifyNonZeroExit(result.stderr, input.classifyFailure); const error = VcsProcessExitError.fromProcessExit( baseError, { @@ -324,9 +320,13 @@ export const make = Effect.gen(function* () { stderrTruncated: result.stderrTruncated, }, failureKind, - input.command === "git" && - failureKind === "command-failed" && - isTransientGitExit(result.stderr), + { + retryable: + input.command === "git" && + failureKind === "command-failed" && + isTransientGitExit(result.stderr), + ...(input.notFoundDetail === undefined ? {} : { notFoundDetail: input.notFoundDetail }), + }, ); if ( input.command === "git" && @@ -374,7 +374,9 @@ export const make = Effect.gen(function* () { }), ); } - return yield* input.command === "gh" ? githubProcesses.withPermits(1)(bounded) : bounded; + return yield* input.concurrency === undefined + ? bounded + : keyedPermit(input.concurrency)(bounded); }); return VcsProcess.of({ run }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 41859b9dd907..71427713e5b8 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -243,7 +243,7 @@ export const make = Effect.gen(function* () { ); const cacheRef = yield* Ref.make(new Map()); // One permit per cwd for remote reads that write the cache. Without it a - // periodic poll that started before `gh pr create` can finish after the + // periodic poll that started before an agent creates a PR can finish after the // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. const remoteWriteLocks = yield* KeyedLock.make(); const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index f92e4fc1d5c4..3d59d61c7aa6 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -132,7 +132,7 @@ export class VcsProcessExitError extends Schema.TaggedError context: VcsProcessErrorContext, error: VcsProcessExitFailure, failureKind: VcsProcessExitFailureKind, - retryable?: boolean, + options?: { readonly retryable?: boolean; readonly notFoundDetail?: string }, ) { const detail = failureKind === "authentication" @@ -140,11 +140,7 @@ export class VcsProcessExitError extends Schema.TaggedError : failureKind === "rate-limited" ? "API rate limit exceeded." : failureKind === "not-found" - ? context.command === "glab" - ? "Merge request not found." - : context.command === "gh" || context.command === "az" - ? "Pull request not found." - : "VCS resource not found." + ? (options?.notFoundDetail ?? "VCS resource not found.") : "Process exited with a non-zero status."; return new VcsProcessExitError({ @@ -152,7 +148,7 @@ export class VcsProcessExitError extends Schema.TaggedError exitCode: error.exitCode, detail, failureKind, - ...(retryable === true ? { retryable: true } : {}), + ...(options?.retryable === true ? { retryable: true } : {}), stderrLength: error.stderr.length, stderrTruncated: error.stderrTruncated, }); diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts deleted file mode 100644 index bb3ad5716fdb..000000000000 --- a/packages/shared/src/sourceControl.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; -import { expect, it } from "vite-plus/test"; - -import { getChangeRequestTerminologyForKind } from "./sourceControl.ts"; - -it("calls a Forgejo change request a pull request", () => { - expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("forgejo"))).toEqual({ - shortLabel: "PR", - singular: "pull request", - }); -}); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index f7039961f975..31fba958776e 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,141 +1,3 @@ -import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; - -export interface ChangeRequestPresentation { - readonly icon: - | "github" - | "gitlab" - | "forgejo" - | "azure-devops" - | "bitbucket" - | "gitcafe" - | "change-request"; - readonly providerName: string; - readonly shortName: string; - readonly longName: string; - readonly pluralLongName: string; - readonly providerLongName: string; - readonly checkoutCommandExample?: string; - readonly urlExample: string; -} - -export interface ChangeRequestTerminology { - readonly shortLabel: string; - readonly singular: string; -} - -const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "github", - providerName: "GitHub", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "GitHub pull request", - checkoutCommandExample: "gh pr checkout 123", - urlExample: "https://github.com/owner/repo/pull/42", -}; - -const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "gitlab", - providerName: "GitLab", - shortName: "MR", - longName: "merge request", - pluralLongName: "merge requests", - providerLongName: "GitLab merge request", - checkoutCommandExample: "glab mr checkout 123", - urlExample: "https://gitlab.com/group/project/-/merge_requests/42", -}; - -const FORGEJO_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "forgejo", - providerName: "Forgejo", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "Forgejo pull request", - checkoutCommandExample: "tea pr checkout 123", - urlExample: "https://codeberg.org/owner/repo/pulls/42", -}; - -const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "azure-devops", - providerName: "Azure DevOps", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "Azure DevOps pull request", - checkoutCommandExample: "az repos pr checkout --id 123", - urlExample: "https://dev.azure.com/org/project/_git/repo/pullrequest/42", -}; - -const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "bitbucket", - providerName: "Bitbucket", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "Bitbucket pull request", - urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", -}; - -const GITCAFE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "gitcafe", - providerName: "GitCafe", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "GitCafe pull request", - checkoutCommandExample: "cafe pr checkout 123", - urlExample: "https://git.cafe/owner/repo/pulls/42", -}; - -const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "change-request", - providerName: "source control", - shortName: "change request", - longName: "change request", - pluralLongName: "change requests", - providerLongName: "change request", - urlExample: "#42", -}; - -function resolveChangeRequestPresentation( - provider: SourceControlProviderInfo | null | undefined, -): ChangeRequestPresentation { - switch (provider?.kind) { - case "github": - case undefined: - return GITHUB_CHANGE_REQUEST_PRESENTATION; - case "gitlab": - return GITLAB_CHANGE_REQUEST_PRESENTATION; - case "forgejo": - return FORGEJO_CHANGE_REQUEST_PRESENTATION; - case "azure-devops": - return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; - case "bitbucket": - return BITBUCKET_CHANGE_REQUEST_PRESENTATION; - case "gitcafe": - return GITCAFE_CHANGE_REQUEST_PRESENTATION; - default: - return GENERIC_CHANGE_REQUEST_PRESENTATION; - } -} - -function resolveChangeRequestPresentationForKind( - kind: SourceControlProviderKind, -): ChangeRequestPresentation { - return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); -} - -export function getChangeRequestTerminologyForKind( - kind: SourceControlProviderKind, -): ChangeRequestTerminology { - const presentation = resolveChangeRequestPresentationForKind(kind); - return { - shortLabel: presentation.shortName, - singular: presentation.longName, - }; -} - const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/; export function isSshRemoteUrl(remoteUrl: string): boolean { diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts index 9a39f74d290a..ff2ddd033997 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -70,7 +70,20 @@ describe("AzureDevOpsCli.layer", () => { assert.strictEqual(result.headSha, "a".repeat(40)); assert.strictEqual(result.state, "open"); assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); - assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { + const { classifyFailure, ...call } = mockRun.mock.calls.at(-1)?.[0] ?? {}; + assert.strictEqual( + classifyFailure?.("Please run az login to setup account."), + "authentication", + ); + assert.strictEqual( + classifyFailure?.("Please run 'az login' to setup account."), + "authentication", + ); + assert.strictEqual( + classifyFailure?.("TF401180: The requested pull request was not found."), + "not-found", + ); + assert.deepStrictEqual(call, { operation: "AzureDevOpsCli.execute", command: "az", args: [ @@ -86,6 +99,7 @@ describe("AzureDevOpsCli.layer", () => { "json", ], cwd: "/repo", + notFoundDetail: "Pull request not found.", timeoutMs: 30_000, }); assert.strictEqual(result.isCrossRepository, false); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts index c333a220c6d3..b12901d6aceb 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts @@ -9,6 +9,7 @@ import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, + type VcsProcessExitFailureKind, } from "@t3tools/contracts"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; @@ -350,6 +351,17 @@ function decodeAzureDevOpsJson( ); } +const classifyAzFailure = (stderr: string): VcsProcessExitFailureKind | null => { + const normalized = stderr.toLowerCase(); + if (normalized.includes("az devops login") || /please run '?az login'?/.test(normalized)) { + return "authentication"; + } + return normalized.includes("pull request") && + (normalized.includes("not found") || normalized.includes("does not exist")) + ? "not-found" + : null; +}; + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const { process } = yield* SourceControlHost.SourceControlHost; @@ -361,6 +373,8 @@ export const make = Effect.gen(function* () { command: "az", args: input.args, cwd: input.cwd, + classifyFailure: classifyAzFailure, + notFoundDetail: "Pull request not found.", timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }) diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 35cc5ddaeba3..a253780a6a3d 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -125,6 +125,11 @@ export interface SourceControlClientDefinition { link: ChangeRequestLink, ) => boolean; readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; + /** + * The ref the target repository publishes change request `number`'s head under, which names the + * head even when it lives in a fork. Absent for a host that publishes none. + */ + readonly changeRequestHeadRef?: (number: number) => string; /** Null when this host does not claim the remote. Asked in the registry's recognition order. */ readonly recognizeRemote: (remote: GitRemoteHost) => SourceControlProviderInfo | null; /** The host change requests in the project are listed and keyed under. */ diff --git a/packages/source-control-core/src/server/SourceControlHost.ts b/packages/source-control-core/src/server/SourceControlHost.ts index 330fea4bbfaa..711538d0aa7e 100644 --- a/packages/source-control-core/src/server/SourceControlHost.ts +++ b/packages/source-control-core/src/server/SourceControlHost.ts @@ -4,6 +4,7 @@ import type { ServerSettingsError, VcsError, VcsListRemotesResult, + VcsProcessExitFailureKind, VcsSwitchRefInput, VcsSwitchRefResult, } from "@t3tools/contracts"; @@ -25,6 +26,18 @@ export interface SourceControlProcessInput { readonly maxOutputBytes?: number; readonly outputMode?: "error" | "truncate" | undefined; readonly appendTruncationMarker?: boolean; + /** + * Reads a failure the generic rules cannot, such as a host CLI's sign-in hint, from its stderr. + * Null leaves it to them. + */ + readonly classifyFailure?: (stderr: string) => VcsProcessExitFailureKind | null; + /** The error's detail when the command reports something it was asked for as missing. */ + readonly notFoundDetail?: string; + /** + * Runs sharing a key run at most `limit` at a time, for a host that throttles bursts of its CLI. + * The first run of a key fixes its limit. + */ + readonly concurrency?: { readonly key: string; readonly limit: number }; } export interface SourceControlProcessOutput { diff --git a/packages/source-control-core/src/server/SourceControlProvider.ts b/packages/source-control-core/src/server/SourceControlProvider.ts index 23e26dcf0d7f..90e9acfcb257 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -142,6 +142,25 @@ export class SourceControlProvider extends Context.Service< }) => Effect.Effect; }) => Effect.Effect; readonly resolveLink?: ResolveSourceControlLink; + /** + * Whether `createChangeRequest` from `cwd` targets `repository`, the `owner/name` of the + * context's remote, for a host whose tools let configuration outside the remotes name another + * repository. Absent means it does. + */ + readonly createsChangeRequestsIn?: (input: { + readonly cwd: string; + readonly context: SourceControlProviderContext; + readonly repository: string | null; + }) => Effect.Effect; + /** + * Told when T3 records `baseBranch`, a branch name on the host, as the base `branch` was cut + * from, for a host whose own tools keep that in git config of their own. + */ + readonly recordBranchBase?: (input: { + readonly cwd: string; + readonly branch: string; + readonly baseBranch: string; + }) => Effect.Effect; readonly listChangeRequests: (input: { readonly cwd: string; readonly context?: SourceControlProviderContext; diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index 1411a42c1e23..f11e7f58aaa2 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -21,6 +21,8 @@ const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u; const KIND = SourceControlProviderKind.make("forgejo"); +const changeRequestHeadRef = (number: number) => `refs/pull/${number}/head`; + /** * The repository's browser URL the server resolved from the signed-in account, which is the * only place a Forgejo mounted below a path says so. @@ -77,7 +79,7 @@ export const definition = defineSourceControlClient({ // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. checkoutCommand: ({ number, repositoryUrl }) => repositoryUrl - ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD` + ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' ${changeRequestHeadRef(number)} && git checkout -B pulls/${number} FETCH_HEAD` : null, authorProfileUrl: () => null, // Forgejo refuses a change request review without a summary, even with inline comments. @@ -86,6 +88,7 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: () => null, + changeRequestHeadRef, recognizeRemote: ({ remoteUrl, host, hostname }) => hostname === "codeberg.org" || hostname.split(".").includes("forgejo") || diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 86268f29d408..f60f10e72389 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -121,6 +121,7 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: (identity) => projectChangeRequestHost(identity, KIND), + changeRequestHeadRef: (number) => `refs/pull/${number}/head`, // `*.ghe.com` is GitHub Enterprise Cloud with data residency, one subdomain per enterprise. recognizeRemote: ({ host, hostname }) => hostname === "github.com" || diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index b2d0b7d0ea34..58aed003e3b9 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -19,6 +19,12 @@ import type { ServerSettings } from "@t3tools/contracts"; import * as GitHubClient from "../client/definition.ts"; +/** + * Every gh run on the server shares this. A burst of them (a status poll across many checkouts) + * otherwise starts enough at once to trip GitHub's secondary rate limit. + */ +export const GH_PROCESS_CONCURRENCY = { key: "gh", limit: 4 } as const; + const TOKEN_TTL = Duration.minutes(5); const MISSING_TTL = Duration.seconds(10); @@ -146,6 +152,7 @@ export const make = Effect.gen(function* () { // Never let gh print the token into a debug log. env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, timeoutMs: 10_000, + concurrency: GH_PROCESS_CONCURRENCY, }) .pipe( Effect.mapError((error) => diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index a8dfbabf7d28..2c2293efb658 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -1051,3 +1051,31 @@ it.effect.each([ ); }, ); + +it.effect("says a GH_REPO naming an unparseable host targets no remote rather than failing", () => { + const { layer } = harness({ + remotes: remotesOutput(["upstream", "git@github.com:acme/web.git"]), + resolved: "", + api: {}, + }); + return Effect.gen(function* () { + const provider = yield* GitHubSourceControlProvider.make; + const targets = yield* provider.createsChangeRequestsIn!({ + cwd: "/repo", + context: { + provider: { + kind: SourceControlProviderKind.make("github"), + name: "GitHub", + baseUrl: "https://github.com", + }, + remoteName: "upstream", + remoteUrl: "git@github.com:acme/web.git", + }, + repository: "acme/web", + }); + assert.isFalse(targets); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Environment, { GH_REPO: "bad host/acme/web" }), + ); +}); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index b4c2b5107fe0..22bbf99f2ab9 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -26,6 +26,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; +import { GH_PROCESS_CONCURRENCY } from "./GitHubCredentials.ts"; import * as GitHubRest from "./GitHubRest.ts"; import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; import * as GitHubChangeRequestTemplate from "./gitHubChangeRequestTemplate.ts"; @@ -235,7 +236,9 @@ export const makeDiscovery = Effect.gen(function* () { ); const cli = yield* probeSourceControlProvider({ cwd, - process, + process: { + run: (input) => process.run({ ...input, concurrency: GH_PROCESS_CONCURRENCY }), + }, spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, }); const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; @@ -893,6 +896,55 @@ export const make = Effect.gen(function* () { }), readChangeRequestTemplate: ({ cwd, treeish }) => GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute), + // `createChangeRequest` targets what `resolveRepository` picks: GH_REPO when it parses, else + // the remote `gh repo set-default` marked (`gh-resolved`). An HTTPS remote keeps a + // non-default port in the context's host, and the mark is then ignored. + createsChangeRequestsIn: Effect.fn("GitHubSourceControlProvider.createsChangeRequestsIn")( + function* ({ cwd, context, repository }) { + const host = URL.canParse(context.provider.baseUrl) + ? new URL(context.provider.baseUrl).host + : null; + const target = repository?.toLowerCase(); + const fromEnvironment = parseGitHubRepositorySelector( + environment.GH_REPO ?? "", + host ?? "", + ); + if (fromEnvironment !== null) { + const { host: targetHost, owner, name } = fromEnvironment; + return ( + host !== null && + URL.canParse(`https://${targetHost}`) && + new URL(`https://${targetHost}`).host === host && + `${owner}/${name}`.toLowerCase() === target + ); + } + const mark = yield* git + .readConfigValue(cwd, `remote.${context.remoteName}.gh-resolved`) + .pipe(Effect.orElseSucceed(() => null)); + const { locator } = resolveGitHubRepository({ + remotes: `${context.remoteName}\t${context.remoteUrl} (fetch)`, + resolved: mark === null ? "" : `remote.${context.remoteName}.gh-resolved ${mark}`, + hostHint: host ?? undefined, + defaultHost: host ?? "github.com", + }); + return locator === null || `${locator.owner}/${locator.name}`.toLowerCase() === target; + }, + ), + // `gh pr create` run in the checkout, as an agent in a T3 worktree often does, takes its base + // from this key. + recordBranchBase: ({ cwd, branch, baseBranch }) => + git + .execute({ + operation: "GitHubSourceControlProvider.recordBranchBase", + cwd, + args: ["config", `branch.${branch}.gh-merge-base`, baseBranch], + }) + .pipe( + Effect.asVoid, + Effect.catch((cause) => + Effect.logWarning("Could not record the branch base for gh", { cause }), + ), + ), resolveLink: (input) => { // Automatic enrichment must not send ambient CLI credentials to a host from message text. if (input.url.host !== "github.com") return undefined; diff --git a/packages/source-control-github/src/server/gitHubPullRequests.test.ts b/packages/source-control-github/src/server/gitHubPullRequests.test.ts new file mode 100644 index 000000000000..59e8fffa418f --- /dev/null +++ b/packages/source-control-github/src/server/gitHubPullRequests.test.ts @@ -0,0 +1,27 @@ +import * as Result from "effect/Result"; +import { expect, it } from "vite-plus/test"; + +import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; + +// gh before 2.47 names the head repository without its owner, and a fork's checkout needs both. +it("names a fork's head repository from its owner when gh leaves nameWithOwner out", () => { + const decoded = decodeGitHubPullRequestListJson( + JSON.stringify([ + { + number: 642, + title: "fix: use commit as the default git action without origin", + url: "https://github.com/pingdotgg/t3code/pull/642", + baseRefName: "main", + headRefName: "fix/git-action-default-without-origin", + isCrossRepository: true, + headRepository: { id: "R_1", name: "t3code" }, + headRepositoryOwner: { login: "binbandit" }, + }, + ]), + ); + + expect(Result.getOrThrow(decoded)[0]).toMatchObject({ + headRepositoryNameWithOwner: "binbandit/t3code", + headRepositoryOwnerLogin: "binbandit", + }); +}); diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index e944270a94e8..d9262f5abf73 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -52,6 +52,7 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: () => null, + changeRequestHeadRef: (number) => `refs/merge-requests/${number}/head`, recognizeRemote: ({ host, hostname }) => hostname === "gitlab.com" || hostname.split(".").includes("gitlab") ? { diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts index aea251144e8e..405c6a520040 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -11,6 +11,7 @@ import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, + type VcsProcessExitFailureKind, } from "@t3tools/contracts"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; @@ -409,6 +410,14 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } +// glab prints a missing project or merge request as a bare 404. +const classifyGlabFailure = (stderr: string): VcsProcessExitFailureKind | null => { + const normalized = stderr.toLowerCase(); + if (normalized.includes("glab auth login")) return "authentication"; + if (normalized.includes("not found") || normalized.includes("404")) return "not-found"; + return null; +}; + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const { process } = yield* SourceControlHost.SourceControlHost; @@ -423,6 +432,8 @@ export const make = Effect.gen(function* () { command: "glab", args: input.args, cwd: input.cwd, + classifyFailure: classifyGlabFailure, + notFoundDetail: "Merge request not found.", timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(input.stdin === undefined ? {} : { stdin: input.stdin }), ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), From 233f6eac69def8ba59581a487837e15b48cf1163 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:34 -0700 Subject: [PATCH 06/65] refactor(source-control): pull request behaviour comes from forge capabilities (#23996) Co-authored-by: Claude Opus 5.5 --- .../SettingsServerControlsRouteScreen.tsx | 2 +- .../src/mcp/toolkits/pullRequests/tools.ts | 4 +- .../PullRequestSyncReactor.test.ts | 8 +- .../PullRequestSyncReactor.ts | 12 +- .../pullRequestWatch.forgejoComments.test.ts | 12 +- .../orchestration-v2/pullRequestWatch.test.ts | 23 ++-- .../src/orchestration-v2/pullRequestWatch.ts | 14 +-- .../src/orchestration-v2/runtimeLayer.test.ts | 1 + .../grok_transcript.ndjson | 4 +- .../grok_transcript.ndjson | 4 +- .../grok_transcript.ndjson | 4 +- .../grok_transcript.ndjson | 4 +- .../grok_monitor/grok_transcript.ndjson | 4 +- .../grok_prompt_error/grok_transcript.ndjson | 8 +- .../message_steering/grok_transcript.ndjson | 8 +- .../multi_turn/grok_transcript.ndjson | 8 +- .../multi_turn/muse_transcript.ndjson | 12 +- .../muse_permission/muse_transcript.ndjson | 12 +- .../muse_transcript.ndjson | 6 +- .../muse_transcript.ndjson | 6 +- .../muse_workflow/muse_transcript.ndjson | 6 +- .../queued_turn/grok_transcript.ndjson | 8 +- .../fixtures/simple/grok_transcript.ndjson | 4 +- .../fixtures/simple/muse_transcript.ndjson | 6 +- .../subagent_v2/muse_transcript.ndjson | 6 +- .../fixtures/todo_list/grok_transcript.ndjson | 4 +- .../grok_transcript.ndjson | 4 +- .../turn_interrupt/grok_transcript.ndjson | 4 +- .../turn_interrupt/muse_transcript.ndjson | 6 +- .../pullRequest/PullRequestService.test.ts | 69 +++++++++--- .../src/pullRequest/PullRequestService.ts | 101 ++++++++++------- .../pullRequest/PullRequestCodeTab.tsx | 20 ++-- .../pullRequest/PullRequestDetailPanel.tsx | 1 + .../pullRequest/PullRequestGuideTab.tsx | 6 +- .../pullRequest/guide/GuideExcerpt.tsx | 13 ++- .../settings/ProjectDefaultsSettings.tsx | 2 +- apps/web/src/reviewCommentContext.test.ts | 33 ++++++ apps/web/src/reviewCommentContext.ts | 40 +++++++ docs/internals/overview.md | 3 +- .../src/state/pullRequestRouting.ts | 5 +- packages/contracts/src/pullRequest.ts | 103 +++++------------- .../src/server/runtimeInstructions.ts | 2 +- .../src/client/definition.ts | 3 + .../server/AzureDevOpsPullRequestProvider.ts | 7 ++ .../server/BitbucketPullRequestProvider.ts | 7 ++ .../src/client/definition.ts | 5 + .../src/server/PullRequestProvider.ts | 14 +++ .../src/client/definition.ts | 1 + .../src/server/ForgejoPullRequestProvider.ts | 8 ++ .../src/client/definition.ts | 1 + .../src/client/definition.ts | 1 + .../src/server/GitHubPullRequestProvider.ts | 15 +++ .../src/server/gitHubPullRequestJson.test.ts | 32 ++++++ .../src/server/gitHubPullRequestJson.ts | 20 +++- .../src/client/definition.ts | 1 + .../src/server/GitLabPullRequestProvider.ts | 7 ++ 56 files changed, 480 insertions(+), 244 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index e33f1b8d5cb5..cb9e04f58e26 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -368,7 +368,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { write({ removeAgentCreditsOnMerge: value })} diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 510f6831d7eb..4e476863700b 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -43,7 +43,7 @@ export const PullRequestTargetInput = Schema.Struct({ url: Schema.optional( TrimmedNonEmptyString.annotate({ description: - "The pull request's web URL, for example https://github.com/owner/repo/pull/123. Preferred when you have it; host, repository and number are read from it.", + "The pull request's web URL as its host shows it, for example https://git.example.com/owner/repo/pull/123 or https://git.example.com/group/project/-/merge_requests/123. Preferred when you have it; host, repository and number are read from it.", }), ), repository: Schema.optional( @@ -60,7 +60,7 @@ export const PullRequestTargetInput = Schema.Struct({ host: Schema.optional( TrimmedNonEmptyString.annotate({ description: - "Host the repository lives on, for example github.com. Defaults to the host of this thread's project.", + "Host the repository lives on, for example git.example.com. Defaults to the host of this thread's project.", }), ), }); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 54f3e4b25553..09424a2a91dd 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1337,7 +1337,11 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("reads open links fresh only when a run that ran a merge command ends", () => + it.effect.each([ + "gh pr merge 7 --squash 2>&1 | tail -3", + "cafe pr merge 7 --squash", + "az repos pr update --id 7 --status completed", + ])("reads open links fresh only when a run that ran `%s` ends", (mergeCommand) => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); @@ -1366,7 +1370,7 @@ describe("PullRequestSyncReactor", () => { yield* Queue.offerAll(fixture.domainEvents, [ commandRan(ThreadId.make("other"), "gh pr view 9"), runUpdated(ThreadId.make("other"), "completed"), - commandRan(ThreadId.make("agent"), "gh pr merge 7 --squash 2>&1 | tail -3"), + commandRan(ThreadId.make("agent"), mergeCommand), runUpdated(ThreadId.make("agent"), "completed"), ]); yield* Queue.take(fixture.snapshotReads); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 5e3dbbb6d937..5d0b47eb4c49 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -39,10 +39,14 @@ import { isTerminalRunStatus } from "./ThreadManagementService.ts"; const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; // Every server polls every open linked pull request, and most of them sit untouched for days. // One nobody has updated for QUIET_AFTER_MS is read on the quiet cadence instead of each sweep; -// requested refreshes, watches and `gh pr merge` still read it at once. +// requested refreshes, watches and a host CLI's merge command still read it at once. const QUIET_AFTER_MS = 30 * 60 * 1_000; const QUIET_SYNC_INTERVAL_MS = 5 * 60 * 1_000; -const PULL_REQUEST_CLOSE_COMMAND = /\b(?:gh\s+pr|glab\s+mr)\s+(?:merge|close)\b/u; + +const isChangeRequestCloseCommand = (command: string) => + sourceControlClients.definitions.some( + (definition) => definition.changeRequestCloseCommand?.test(command) === true, + ); const isPullRequestProviderError = Schema.is(PullRequestProviderError); @@ -533,14 +537,14 @@ export const make = Effect.gen(function* () { requestSync, { discard: true }, ); - // An agent can merge or close its pull request from a shell (`gh pr merge`), which + // An agent can merge or close its pull request from a shell with its host's CLI, which // sends no merge notification. When a run that ran such a command ends, read the // thread's open links fresh, so settlement does not wait for the next sweep and the // cached summary. Other runs add no host reads. case "turn-item.updated": if ( event.payload.type === "command_execution" && - PULL_REQUEST_CLOSE_COMMAND.test(event.payload.input) + isChangeRequestCloseCommand(event.payload.input) ) { closeCommandThreads.add(event.threadId); } diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts index 4301e38fe24c..57d0f5888309 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts @@ -1,4 +1,4 @@ -import { SourceControlProviderKind, type ThreadPullRequestWatch } from "@t3tools/contracts"; +import type { ThreadPullRequestWatch } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; import { @@ -30,7 +30,15 @@ describe("Forgejo comment watch boundary", () => { wakes: 2, } satisfies ThreadPullRequestWatch; const finalDetail = { - provider: SourceControlProviderKind.make("forgejo"), + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: false, + review: { inlineComment: true, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: true, listCandidates: true }, + }, headSha: seen.headSha, checks: [], mergeability: "mergeable", diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index ea27a0fffe58..a3da5180408d 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -1,5 +1,4 @@ import { - SourceControlProviderKind, type PullRequestCheck, PullRequestComment, ThreadPullRequestWatch, @@ -40,8 +39,19 @@ const check = (name: string, status: PullRequestCheck["status"]): PullRequestChe type Detail = Parameters[1]; +const capabilities = (checksAlwaysReport: boolean): Detail["capabilities"] => ({ + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: false, + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: false, listCandidates: false }, + checksAlwaysReport, +}); + const detail = (overrides: Partial = {}): Detail => ({ - provider: SourceControlProviderKind.make("github"), + capabilities: capabilities(true), headSha: "aaaaaaaaaa", checks: [check("lint", "success"), check("test", "pending")], mergeability: "mergeable", @@ -364,11 +374,8 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(pushed.flat(), ["no-checks"]); }); - it("never reports missing checks on a host whose checks it cannot read", () => { - const azure = detail({ - provider: SourceControlProviderKind.make("azure-devops"), - checks: [], - }); - assert.isTrue(anHourLater(watch(), azure).every((kinds) => kinds.length === 0)); + it("never reports missing checks on a host where an empty list says nothing", () => { + const silent = detail({ capabilities: capabilities(false), checks: [] }); + assert.isTrue(anHourLater(watch(), silent).every((kinds) => kinds.length === 0)); }); }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 371cb46de8d1..0701354ee2c6 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -12,7 +12,7 @@ import * as DateTime from "effect/DateTime"; * this only stops a chatty bot looping an agent that is replying to it. */ export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; -/** How long a GitHub head may go without any check before the agent is told none reported. */ +/** How long a head may go without any check before the agent is told none reported. */ const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; const LISTED_ITEMS = 10; const SNIPPET_LENGTH = 200; @@ -38,7 +38,7 @@ export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, detail: Pick< PullRequestDetail, - "provider" | "headSha" | "checks" | "mergeability" | "viewer" | "author" + "capabilities" | "headSha" | "checks" | "mergeability" | "viewer" | "author" >, remarks: ReadonlyArray | null, now: number, @@ -95,10 +95,10 @@ export function evaluatePullRequestWatch( // Only a head never read with checks waits for them: an empty read after checks were seen is // the failed read above. A watch saved before this field may have seen pending checks on its - // head, so its clock starts only at the next push. Other hosts can lack a checks API (Azure - // DevOps pipelines), where an empty list says nothing, so only GitHub waits. + // head, so its clock starts only at the next push. Where a host's empty list can mean the + // repository runs no checks at all, nothing waits. let checksAwaitedSince = watch.checksAwaitedSince; - if (detail.provider === "github") { + if (detail.capabilities.checksAlwaysReport === true) { const awaitedBefore = headMoved ? DateTime.formatIso(DateTime.makeUnsafe(now)) : (watch.checksAwaitedSince ?? null); @@ -113,7 +113,7 @@ export function evaluatePullRequestWatch( const own = (detail.viewer ?? detail.author?.login)?.toLowerCase(); const through = Date.parse(watch.remarksThrough); const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt; - // GitHub times are per second, so remarks at the boundary time are told apart by ID. + // Hosts can stamp remarks to the second, so remarks at the boundary time are told apart by ID. const fresh = (remarks ?? []).filter((remark) => { const at = Date.parse(activeAt(remark)); return ( @@ -133,7 +133,7 @@ export function evaluatePullRequestWatch( if (detail.mergeability === "conflicting" && !watch.conflicting) { changes.push({ kind: "conflicting" }); } - // "unknown" is GitHub still computing after a push; only a clean answer clears a conflict. + // "unknown" is a host still computing after a push; only a clean answer clears a conflict. const conflicting = detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting"; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9a7724922ba3..9f0edb83f0f3 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -183,6 +183,7 @@ const watchedPullRequestDetail = (input: { actions: [], mergeMethods: [], search: false, + checksAlwaysReport: true, review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, reviewers: { request: false, listCandidates: false }, }, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson index 43214e9a4e80..80ca85e01ba0 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson @@ -27,8 +27,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7-build-fast","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790383223057,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2700,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790383224553,"promptId":"t3-xai-prompt-1","streamStartMs":1790383223875,"turnStartMs":1790383223110,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2700,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790383224553,"promptId":"t3-xai-prompt-1","streamStartMs":1790383223875,"turnStartMs":1790383223110,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson index 92665d0833f8..b7158d6275a3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson @@ -27,8 +27,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7-build-fast","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790405167462,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:session_summary_generated","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_summary_generated","session_summary":"Background for-loop sleep echo tock command"}}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","title":"Background for-loop sleep echo tock command"}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson index d0aa5f7a6f06..97c36f3e27e1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson @@ -30,8 +30,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7-build-fast","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790806313459,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_run_started","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-1","count":1},"_meta":{"agentTimestampMs":1790806313470}}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/server_status","frame":{"kind":"notification","method":"_x.ai/mcp/server_status","params":{"sessionId":"00000000-0000-4000-8000-000000000001","name":"uidotsh","source":"local","status":"ready","reason":"initialized","tools":null}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/init_progress","frame":{"kind":"notification","method":"_x.ai/mcp/init_progress","params":{"total":2,"connected":2,"sessionId":"00000000-0000-4000-8000-000000000001"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson index 8d983985a08b..92a295504b63 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790321078856,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2695,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790321080805,"promptId":"t3-xai-prompt-1","streamStartMs":1790321079590,"turnStartMs":1790321078886,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2695,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790321080805,"promptId":"t3-xai-prompt-1","streamStartMs":1790321079590,"turnStartMs":1790321078886,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson index 0229a0af335b..d513513fad0b 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790319032095,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790319033828,"promptId":"t3-xai-prompt-1","streamStartMs":1790319032800,"turnStartMs":1790319032133,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790319033828,"promptId":"t3-xai-prompt-1","streamStartMs":1790319032800,"turnStartMs":1790319032133,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson index d707290fcd7e..a63065d9a759 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson @@ -26,8 +26,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: grok prompt error fixture\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790805582748,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_run_started","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-1","count":1},"_meta":{"agentTimestampMs":1790805582754}}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/server_status","frame":{"kind":"notification","method":"_x.ai/mcp/server_status","params":{"sessionId":"00000000-0000-4000-8000-000000000001","name":"uidotsh","source":"local","status":"ready","reason":"initialized","tools":null}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/init_progress","frame":{"kind":"notification","method":"_x.ai/mcp/init_progress","params":{"total":2,"connected":2,"sessionId":"00000000-0000-4000-8000-000000000001"}}} @@ -41,8 +41,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","error":{"code":-32603,"message":"Internal error","data":{"message":"API error (status 400 Bad Request): invalid_request_error: grok prompt error fixture","http_status":400}}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-2","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790805583632,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_run_started","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-2","count":1},"_meta":{"agentTimestampMs":1790805583637}}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_execution","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_execution","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-2","runs":[{"name":"global/settings:user_prompt_submit[0].hooks[0]","status":{"status":"success","elapsed_ms":422}}]},"_meta":{"eventId":"00000000-0000-4000-8000-000000000001-9","agentTimestampMs":1790805584060}}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson index 03857a3d1446..a7f4f9fbbd8c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: steering fixture initial response\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300235622,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"expect_outbound","label":"notification:session/cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"00000000-0000-4000-8000-000000000001","_meta":{"cancelTrigger":"ctrl_c"}}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:turn_completed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"turn_completed","prompt_id":"t3-xai-prompt-1","stop_reason":"cancelled","elapsed_ms":22},"_meta":{"eventId":"00000000-0000-4000-8000-000000000001-4","agentTimestampMs":1790300235647,"cancelTrigger":"ctrl_c","cancellationCategory":"MidTurnAbort"}}}} {"type":"emit_inbound","label":"notification:_x.ai/session/prompt_complete","frame":{"kind":"notification","method":"_x.ai/session/prompt_complete","params":{"sessionId":"00000000-0000-4000-8000-000000000001","promptId":"t3-xai-prompt-1","stopReason":"cancelled","agentResult":null,"cancelTrigger":"ctrl_c","cancellationCategory":"MidTurnAbort","cancellationContext":{"trigger":"ctrl_c"}}}} @@ -32,8 +32,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":2263,"modelId":"grok-4.7","cancellationCategory":"MidTurnAbort","cancellationContext":{"trigger":"ctrl_c"},"cancelTrigger":"ctrl_c"}}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Actually, respond with exactly: steering fixture observed"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300235729,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-8","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-9","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson index 92c250e44b63..470e15d96342 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson @@ -24,8 +24,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: first fixture turn complete\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790299188461,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:session_summary_generated","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_summary_generated","session_summary":"First fixture turn complete"}}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","title":"First fixture turn complete"}}}} @@ -57,8 +57,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":22212,"modelId":"grok-4.7","inputTokens":22168,"outputTokens":36,"cachedReadTokens":1664,"reasoningTokens":32,"usage":{"inputTokens":22168,"outputTokens":36,"totalTokens":22204,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":9575,"costUsdTicks":142990400,"modelUsage":{"grok-4.7-build":{"inputTokens":22168,"outputTokens":36,"totalTokens":22204,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":9575,"costUsdTicks":142990400}},"numTurns":1}}}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790299198569,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":22571,"eventId":"00000000-0000-4000-8000-000000000001-31","agentTimestampMs":1790299207228,"promptId":"t3-xai-prompt-2","streamStartMs":1790299199366,"turnStartMs":1790299198616,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":22571,"eventId":"00000000-0000-4000-8000-000000000001-32","agentTimestampMs":1790299207228,"promptId":"t3-xai-prompt-2","streamStartMs":1790299199366,"turnStartMs":1790299198616,"updateType":"AgentThoughtChunk","chunkId":2}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}},"_meta":{"totalTokens":22571,"eventId":"00000000-0000-4000-8000-000000000001-33","agentTimestampMs":1790299207228,"promptId":"t3-xai-prompt-2","streamStartMs":1790299199366,"turnStartMs":1790299198616,"updateType":"AgentThoughtChunk","chunkId":3}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson index fb6046b8533b..d16f57eef5ee 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6b-707e-7000-9bb5-4079c7772ad0/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:30:11.238412Z","updatedAt":"2026-10-08T07:30:11.238416Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444611408}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6b-707e-7000-9bb5-4079c7772ad0/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:30:11.238412Z","updatedAt":"2026-10-08T07:30:11.238416Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"37b19bd1-b212-4532-a0c9-a6677f303e26","sequence":5},"last":{"id":"37b19bd1-b212-4532-a0c9-a6677f303e26","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6b-707e-7001-b2f4-23db74e60097","clientName":"t3_code"},"emittedAtMs":1791444611412}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: first fixture turn complete"}],"displayText":"Respond with exactly: first fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: first fixture turn complete"}],"displayText":"Respond with exactly: first fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2c530748-943b-4467-8473-da8b04c511c3","sequence":16},"last":{"id":"2c530748-943b-4467-8473-da8b04c511c3","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444611661}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","status":"accepted","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"running","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:3"},"emittedAtMs":1791444611674}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"},"emittedAtMs":1791444611675}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:11.66263Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611675}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20},"last":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:11.680942Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","displayText":"Respond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611689}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:11.66263Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611675}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20},"last":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:11.680942Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","displayText":"Respond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611689}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","sequence":37},"last":{"id":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","sequence":37}},"item":{"itemId":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","kind":"reminderChild","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:30:11.790301Z","fallbackText":"Reminder child session","childSessionId":"c13379db-6761-40b4-a974-b0f1a58717aa","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6b-72ac-7972-a1df-b343b7804b38"}},"emittedAtMs":1791444611795}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6.1","item":{"itemId":"f99b90e1-9d8b-4bf2-8e7a-e240e1cf9321","kind":"agentMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444618068}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6.2","itemId":"f99b90e1-9d8b-4bf2-8e7a-e240e1cf9321","field":"text","delta":"first fixture turn complete"},"emittedAtMs":1791444618068}} @@ -29,12 +29,12 @@ {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"idle","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:16"},"emittedAtMs":1791444621046}} {"type":"emit_inbound","label":"turn/completed","frame":{"jsonrpc":"2.0","method":"turn/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:16","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"688a69c5-e353-4db9-bd8c-1beb033902ba","sequence":79},"last":{"id":"688a69c5-e353-4db9-bd8c-1beb033902ba","sequence":79}},"turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","terminal":"completed","durationMs":9378,"timeToFirstTokenMs":6406},"emittedAtMs":1791444621047}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:17","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"36753b8a-26ce-4935-8127-bc9191cb328b","sequence":80},"last":{"id":"36753b8a-26ce-4935-8127-bc9191cb328b","sequence":80}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444621054}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":4,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"displayText":"Respond with exactly: second fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":4,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"displayText":"Respond with exactly: second fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":4,"result":{"commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","status":"accepted","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"running","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:18"},"emittedAtMs":1791444621333}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:18","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"},"emittedAtMs":1791444621334}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:19","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:21.321391Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621334}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:20","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89},"last":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:21.338944Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","displayText":"Respond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621347}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:19","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:21.321391Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621334}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:20","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89},"last":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:21.338944Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","displayText":"Respond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621347}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","sequence":106},"last":{"id":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","sequence":106}},"item":{"itemId":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","kind":"reminderChild","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:30:21.446966Z","fallbackText":"Reminder child session","childSessionId":"cd97261a-3ea9-480f-9e54-5e59bb077da2","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6b-9869-7061-a4f3-42daa3e3a8f1"}},"emittedAtMs":1791444621455}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21.1","item":{"itemId":"2de26c0d-26b4-4533-8810-918ee44fcd82","kind":"agentMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444623127}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21.2","itemId":"2de26c0d-26b4-4533-8810-918ee44fcd82","field":"text","delta":"second fixture turn complete"},"emittedAtMs":1791444623127}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson index 8b425ec29268..12492cb13bb2 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-39bb-7000-8c93-4594c98e6a11/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:08.30084Z","updatedAt":"2026-10-08T07:32:08.300844Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444728496}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-39bb-7000-8c93-4594c98e6a11/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:08.30084Z","updatedAt":"2026-10-08T07:32:08.300844Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b05fb8ec-7b58-4cf7-a369-379938395230","sequence":5},"last":{"id":"b05fb8ec-7b58-4cf7-a369-379938395230","sequence":5}},"mode":"promptUnmatched","source":"approvalReconfigure","commandId":"01a11a6d-39bb-7001-923e-425cc6129ce7","clientName":"t3_code"},"emittedAtMs":1791444728501}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE."}],"displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE."}],"displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"6159e84a-ae1e-4fae-ae8b-49f41ca34509","sequence":16},"last":{"id":"6159e84a-ae1e-4fae-ae8b-49f41ca34509","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444728769}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","status":"accepted","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"running","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:3"},"emittedAtMs":1791444728783}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"},"emittedAtMs":1791444728783}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:08.769894Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728784}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20},"last":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:08.791728Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728800}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:08.769894Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728784}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20},"last":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:08.791728Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728800}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"29180105-84c4-4f9e-8bfd-003392b93b73","sequence":38},"last":{"id":"29180105-84c4-4f9e-8bfd-003392b93b73","sequence":38}},"item":{"itemId":"29180105-84c4-4f9e-8bfd-003392b93b73","kind":"reminderChild","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:08.905989Z","fallbackText":"Reminder child session","childSessionId":"7c9485f9-26aa-4b56-8ca2-f36cb9cfb8c4","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-3c27-73f2-8c27-cd06a3b7dadf"}},"emittedAtMs":1791444728910}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:7","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54},"last":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32618,"outputTokens":315,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":227},"promptTokens":32618,"totalTokens":32933,"finishReason":"tool_calls","durationMs":2684,"cumulative":{"promptTokens":32618,"outputTokens":315,"totalTokens":32933,"cost":{"usd":0.0033248,"partial":false}}},"emittedAtMs":1791444731684}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:8","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54},"last":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54}},"windowTokens":1007997,"usedTokens":32933,"pressure":"normal"},"emittedAtMs":1791444731684}} @@ -42,12 +42,12 @@ {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"idle","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:23"},"emittedAtMs":1791444747386}} {"type":"emit_inbound","label":"turn/completed","frame":{"jsonrpc":"2.0","method":"turn/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:23","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"7c29ac76-7e09-48c1-a18f-f336126e27d1","sequence":120},"last":{"id":"7c29ac76-7e09-48c1-a18f-f336126e27d1","sequence":120}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","terminal":"completed","durationMs":18608,"timeToFirstTokenMs":4791},"emittedAtMs":1791444747387}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:24","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"1e4afe30-d924-444c-8665-5e763f524e70","sequence":121},"last":{"id":"1e4afe30-d924-444c-8665-5e763f524e70","sequence":121}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444747394}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":5,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED."}],"displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":5,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED."}],"displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":5,"result":{"commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29","status":"accepted","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"running","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:25"},"emittedAtMs":1791444747660}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:25","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"},"emittedAtMs":1791444747660}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:26","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:27.650913Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747660}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:27","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130},"last":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:27.66745Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747676}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:26","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:27.650913Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747660}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:27","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130},"last":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:27.66745Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747676}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:28","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"3b28955d-a87a-4acb-97e9-14cad87fd41f","sequence":148},"last":{"id":"3b28955d-a87a-4acb-97e9-14cad87fd41f","sequence":148}},"item":{"itemId":"3b28955d-a87a-4acb-97e9-14cad87fd41f","kind":"reminderChild","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:27.787099Z","fallbackText":"Reminder child session","childSessionId":"efd21bf1-4fd1-49a2-9c55-91b7fc099765","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-85ed-7b91-8d91-c4ce76b6dfef"}},"emittedAtMs":1791444747794}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:29","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164},"last":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164}},"turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":33401,"outputTokens":201,"cachedTokens":33009,"cacheWriteTokens":0,"cacheReadTokens":33009,"reasoningTokens":108},"promptTokens":33401,"totalTokens":33602,"finishReason":"tool_calls","durationMs":2522,"cumulative":{"promptTokens":99045,"outputTokens":538,"totalTokens":99583,"cacheReadTokens":65506,"cost":{"usd":0.003592512,"partial":false}}},"emittedAtMs":1791444750395}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:30","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164},"last":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164}},"windowTokens":1007997,"usedTokens":33602,"pressure":"normal"},"emittedAtMs":1791444750395}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson index c1462a92c2cc..dbbd70a9b596 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","path":"~/.local/share/muse/sessions/2026/10/11/01a12d1d-a1b7-7000-9697-d430c2e2afd8/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:37:59.14136Z","updatedAt":"2026-10-11T22:37:59.141364Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791758279294}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","path":"~/.local/share/muse/sessions/2026/10/11/01a12d1d-a1b7-7000-9697-d430c2e2afd8/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:37:59.14136Z","updatedAt":"2026-10-11T22:37:59.141364Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:1","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"ed95a9db-eada-4751-a7e6-22ccc9bdabd5","sequence":5},"last":{"id":"ed95a9db-eada-4751-a7e6-22ccc9bdabd5","sequence":5}},"mode":"promptUnmatched","source":"approvalReconfigure","commandId":"01a12d1d-a1b8-7000-8d6c-f1324b724cdb","clientName":"t3_code"},"emittedAtMs":1791758279297}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:2","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"4f170229-d428-4a64-9516-7a355ff3bcdb","sequence":15},"last":{"id":"4f170229-d428-4a64-9516-7a355ff3bcdb","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791758279531}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","status":"accepted","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","status":"running","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:3"},"emittedAtMs":1791758279541}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:3","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"},"emittedAtMs":1791758279542}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:4","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"completed","recordedAt":"2026-10-11T22:37:59.531477Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279542}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:5","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19},"last":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":2,"status":"completed","recordedAt":"2026-10-11T22:37:59.545908Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279553}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:4","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"completed","recordedAt":"2026-10-11T22:37:59.531477Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279542}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:5","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19},"last":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":2,"status":"completed","recordedAt":"2026-10-11T22:37:59.545908Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279553}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","sequence":37},"last":{"id":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","sequence":37}},"item":{"itemId":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","kind":"reminderChild","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:37:59.660065Z","fallbackText":"Reminder child session","childSessionId":"0779d605-54b8-45dc-90b6-91dc58b9a52c","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d1d-a3d4-7712-8b98-26f3c4f9bf38"}},"emittedAtMs":1791758279666}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6.1","item":{"itemId":"21b74b34-fe9f-457c-83f0-cf49257b5948","kind":"reasoning","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"inProgress","summary":[""]}},"emittedAtMs":1791758282481}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6.2","itemId":"21b74b34-fe9f-457c-83f0-cf49257b5948","field":"summary.0","delta":"Spawning a subagent via muse_subagent_spawn to run a shell command and return its exact output, while avoiding direct command execution."},"emittedAtMs":1791758282481}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson index d73fa8b51bf5..f9c5f262fc58 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","path":"~/.local/share/muse/sessions/2026/10/11/01a12d28-7788-7000-a946-c407f380868e/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:49:49.235205Z","updatedAt":"2026-10-11T22:49:49.235209Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791758989388}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","path":"~/.local/share/muse/sessions/2026/10/11/01a12d28-7788-7000-a946-c407f380868e/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:49:49.235205Z","updatedAt":"2026-10-11T22:49:49.235209Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:1","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"29d4e769-78f7-48fa-90cd-3ac9f8fa04bf","sequence":5},"last":{"id":"29d4e769-78f7-48fa-90cd-3ac9f8fa04bf","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d28-7788-7001-a223-c10e52187cdf","clientName":"t3_code"},"emittedAtMs":1791758989391}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:2","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"660e2cfd-318a-4d2d-87bb-0b1375d79fdf","sequence":15},"last":{"id":"660e2cfd-318a-4d2d-87bb-0b1375d79fdf","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791758989619}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d28-7912-7000-993a-afd019e6ac5c","status":"accepted","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","status":"running","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:3"},"emittedAtMs":1791758989632}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:3","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"},"emittedAtMs":1791758989632}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:4","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"completed","recordedAt":"2026-10-11T22:49:49.620067Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989632}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:5","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19},"last":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":2,"status":"completed","recordedAt":"2026-10-11T22:49:49.635395Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989642}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:4","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"completed","recordedAt":"2026-10-11T22:49:49.620067Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989632}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:5","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19},"last":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":2,"status":"completed","recordedAt":"2026-10-11T22:49:49.635395Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989642}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:6","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"44afb415-d6e4-4d51-9002-4deb196d463a","sequence":36},"last":{"id":"44afb415-d6e4-4d51-9002-4deb196d463a","sequence":36}},"item":{"itemId":"44afb415-d6e4-4d51-9002-4deb196d463a","kind":"reminderChild","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:49:49.743418Z","fallbackText":"Reminder child session","childSessionId":"4dd3511e-2115-4fbb-a4c4-383cbb383d63","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d28-7997-79d0-8a69-8ac0acf92a56"}},"emittedAtMs":1791758989750}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:7","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52},"last":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52}},"turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32911,"outputTokens":264,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":143},"promptTokens":32911,"totalTokens":33175,"finishReason":"tool_calls","durationMs":3651,"cumulative":{"promptTokens":32911,"outputTokens":264,"totalTokens":33175,"cost":{"usd":0.0033439000000000003,"partial":false}}},"emittedAtMs":1791758993485}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:8","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52},"last":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52}},"windowTokens":1007997,"usedTokens":33175,"pressure":"normal"},"emittedAtMs":1791758993485}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson index 714b315bf228..50f444b01d5f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson @@ -8,13 +8,13 @@ {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","path":"~/.local/share/muse/sessions/2026/10/11/01a12d5d-405f-7000-89a3-5aa62af6ea23/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T23:47:28.587276Z","updatedAt":"2026-10-11T23:47:28.587281Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:2"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:1","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0c13b14b-877c-4c5b-b05f-8ad250dfd381","sequence":5},"last":{"id":"0c13b14b-877c-4c5b-b05f-8ad250dfd381","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d5d-4060-7000-b6cd-aaabe26f80de","clientName":"t3_code"},"emittedAtMs":1791762448807}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:2","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"28b1f3f4-4448-4a21-a1a0-2df72ad1d326","sequence":6},"last":{"id":"28b1f3f4-4448-4a21-a1a0-2df72ad1d326","sequence":6}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791762448807}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it."}],"displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it."}],"displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:3","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"3f1880e1-81a0-4f21-b536-6b4fa5cf9d12","sequence":16},"last":{"id":"3f1880e1-81a0-4f21-b536-6b4fa5cf9d12","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791762449097}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","status":"accepted","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","status":"running","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:4"},"emittedAtMs":1791762449102}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:4","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"},"emittedAtMs":1791762449103}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:5","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"completed","recordedAt":"2026-10-11T23:47:29.098117Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449103}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:6","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20},"last":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":2,"status":"completed","recordedAt":"2026-10-11T23:47:29.107019Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449131}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:5","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"completed","recordedAt":"2026-10-11T23:47:29.098117Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449103}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:6","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20},"last":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":2,"status":"completed","recordedAt":"2026-10-11T23:47:29.107019Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449131}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"95574bfe-853f-44cc-81f6-88b05d6c8c16","sequence":38},"last":{"id":"95574bfe-853f-44cc-81f6-88b05d6c8c16","sequence":38}},"item":{"itemId":"95574bfe-853f-44cc-81f6-88b05d6c8c16","kind":"reminderChild","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"inProgress","recordedAt":"2026-10-11T23:47:29.328939Z","fallbackText":"Reminder child session","childSessionId":"b16dbb52-7f62-4a5a-84c2-f68d7ea22b18","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d5d-4375-7d72-b5b2-4b096d0df507"}},"emittedAtMs":1791762449336}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7.1","item":{"itemId":"3ce724bc-dacf-400e-af9a-995104717cb1","kind":"reasoning","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"inProgress","summary":[""]}},"emittedAtMs":1791762451554}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7.2","itemId":"3ce724bc-dacf-400e-af9a-995104717cb1","field":"summary.0","delta":"Launching a two-agent ping-pong workflow via the workflow tool while skipping skill reading for a trivial task."},"emittedAtMs":1791762451554}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson index c8937afa2753..e1343593df51 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: first fixture turn complete\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300065163,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2641,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790300066681,"promptId":"t3-xai-prompt-1","streamStartMs":1790300065806,"turnStartMs":1790300065197,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2641,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790300066681,"promptId":"t3-xai-prompt-1","streamStartMs":1790300065806,"turnStartMs":1790300065197,"updateType":"AgentThoughtChunk","chunkId":2}}}} @@ -54,8 +54,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":22196,"modelId":"grok-4.7","inputTokens":22152,"outputTokens":36,"cachedReadTokens":1664,"reasoningTokens":32,"usage":{"inputTokens":22152,"outputTokens":36,"totalTokens":22188,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":1589,"costUsdTicks":142881600,"modelUsage":{"grok-4.7-build":{"inputTokens":22152,"outputTokens":36,"totalTokens":22188,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":1589,"costUsdTicks":142881600}},"numTurns":1}}}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300067131,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":22480,"eventId":"00000000-0000-4000-8000-000000000001-31","agentTimestampMs":1790300068257,"promptId":"t3-xai-prompt-2","streamStartMs":1790300067703,"turnStartMs":1790300067162,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":22480,"eventId":"00000000-0000-4000-8000-000000000001-32","agentTimestampMs":1790300068257,"promptId":"t3-xai-prompt-2","streamStartMs":1790300067703,"turnStartMs":1790300067162,"updateType":"AgentThoughtChunk","chunkId":2}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}},"_meta":{"totalTokens":22480,"eventId":"00000000-0000-4000-8000-000000000001-33","agentTimestampMs":1790300068258,"promptId":"t3-xai-prompt-2","streamStartMs":1790300067703,"turnStartMs":1790300067162,"updateType":"AgentThoughtChunk","chunkId":3}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson index cdc7f802140d..463c61b43fd1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson @@ -24,8 +24,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with the following text: fixture simple ok\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790301055553,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2716,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790301057223,"promptId":"t3-xai-prompt-1","streamStartMs":1790301056936,"turnStartMs":1790301055613,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2716,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790301057223,"promptId":"t3-xai-prompt-1","streamStartMs":1790301056936,"turnStartMs":1790301055613,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson index 8890613a9d77..3d402db32193 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6a-bbb5-7000-ad0b-0c9e728406e5/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:29:25.018803Z","updatedAt":"2026-10-08T07:29:25.018807Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444565334}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6a-bbb5-7000-ad0b-0c9e728406e5/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:29:25.018803Z","updatedAt":"2026-10-08T07:29:25.018807Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"cfd043e8-8728-493d-ba28-252ef91e8a80","sequence":5},"last":{"id":"cfd043e8-8728-493d-ba28-252ef91e8a80","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6a-bbb6-7000-b1a4-a01ea59676ef","clientName":"t3_code"},"emittedAtMs":1791444565340}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with the following text: fixture simple ok"}],"displayText":"Respond with the following text: fixture simple ok","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with the following text: fixture simple ok"}],"displayText":"Respond with the following text: fixture simple ok","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"0f64f4ea-a08c-43e1-af53-cf85cee1a11c","sequence":16},"last":{"id":"0f64f4ea-a08c-43e1-af53-cf85cee1a11c","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444565612}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","status":"accepted","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","status":"running","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:3"},"emittedAtMs":1791444565635}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"},"emittedAtMs":1791444565635}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"completed","recordedAt":"2026-10-08T07:29:25.627042Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565635}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20},"last":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":2,"status":"completed","recordedAt":"2026-10-08T07:29:25.643029Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","displayText":"Respond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565652}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"completed","recordedAt":"2026-10-08T07:29:25.627042Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565635}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20},"last":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":2,"status":"completed","recordedAt":"2026-10-08T07:29:25.643029Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","displayText":"Respond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565652}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","sequence":37},"last":{"id":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","sequence":37}},"item":{"itemId":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","kind":"reminderChild","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:29:25.781511Z","fallbackText":"Reminder child session","childSessionId":"9785bf9d-382d-4781-ab3b-3817c9e6a604","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6a-bef1-7032-974a-059b8765c487"}},"emittedAtMs":1791444565789}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6.1","item":{"itemId":"0a68e576-f554-4700-8853-a68ad2b7179b","kind":"agentMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444567707}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6.2","itemId":"0a68e576-f554-4700-8853-a68ad2b7179b","field":"text","delta":"fixture simple ok"},"emittedAtMs":1791444567707}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson index 965d0f17acdf..8e98fccc55e4 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","path":"~/.local/share/muse/sessions/2026/10/11/01a12d17-75fa-7000-9157-e8365ce94492/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:31:14.73121Z","updatedAt":"2026-10-11T22:31:14.731214Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791757874894}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","path":"~/.local/share/muse/sessions/2026/10/11/01a12d17-75fa-7000-9157-e8365ce94492/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:31:14.73121Z","updatedAt":"2026-10-11T22:31:14.731214Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:1","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"94d1e1ed-1d3e-4cd5-91bf-a51394fc8a0a","sequence":5},"last":{"id":"94d1e1ed-1d3e-4cd5-91bf-a51394fc8a0a","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d17-75fa-7001-a940-79b5d7c648a4","clientName":"t3_code"},"emittedAtMs":1791757874898}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:2","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"1dc98410-05c3-4618-a431-3638055e20df","sequence":15},"last":{"id":"1dc98410-05c3-4618-a431-3638055e20df","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791757875120}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d17-7790-7000-967d-3f2246f38a60","status":"accepted","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","status":"running","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:3"},"emittedAtMs":1791757875131}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:3","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"turnId":"01a12d17-7790-7000-967d-3f2246f38a60","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"},"emittedAtMs":1791757875132}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:4","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"completed","recordedAt":"2026-10-11T22:31:15.120499Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875132}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:5","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19},"last":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":2,"status":"completed","recordedAt":"2026-10-11T22:31:15.136047Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875144}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:4","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"completed","recordedAt":"2026-10-11T22:31:15.120499Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875132}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:5","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19},"last":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":2,"status":"completed","recordedAt":"2026-10-11T22:31:15.136047Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875144}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:6","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"599a220a-d212-4a81-a60d-7284ff290284","sequence":36},"last":{"id":"599a220a-d212-4a81-a60d-7284ff290284","sequence":36}},"item":{"itemId":"599a220a-d212-4a81-a60d-7284ff290284","kind":"reminderChild","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:31:15.249949Z","fallbackText":"Reminder child session","childSessionId":"0b3f4f01-850a-43bd-af36-9b011699354f","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d17-7819-7212-8057-3558c2bdd46d"}},"emittedAtMs":1791757875258}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:7","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51},"last":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51}},"turnId":"01a12d17-7790-7000-967d-3f2246f38a60","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32900,"outputTokens":129,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":36},"promptTokens":32900,"totalTokens":33029,"finishReason":"tool_calls","durationMs":2354,"cumulative":{"promptTokens":32900,"outputTokens":129,"totalTokens":33029,"cost":{"usd":0.0033158000000000003,"partial":false}}},"emittedAtMs":1791757877764}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:8","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51},"last":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51}},"windowTokens":1007997,"usedTokens":33029,"pressure":"normal"},"emittedAtMs":1791757877764}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson index 0ca06d35889b..61770a4ef9c9 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790329153513,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790329155080,"promptId":"t3-xai-prompt-1","streamStartMs":1790329154153,"turnStartMs":1790329153545,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790329155080,"promptId":"t3-xai-prompt-1","streamStartMs":1790329154153,"turnStartMs":1790329153545,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson index 81590cc061e7..b715de016e7d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson @@ -24,8 +24,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790329142568,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2691,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790329143906,"promptId":"t3-xai-prompt-1","streamStartMs":1790329143232,"turnStartMs":1790329142589,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2691,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790329143906,"promptId":"t3-xai-prompt-1","streamStartMs":1790329143232,"turnStartMs":1790329142589,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson index 100804ae950c..5cdbc179a518 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson @@ -23,6 +23,6 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300234968,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"runtime_exit","status":"cancelled"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson index 0d72ecd0dcb4..b23d87e93614 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson @@ -7,14 +7,14 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-2d3d-7000-8178-411fdb90bc34/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:05.094285Z","updatedAt":"2026-10-08T07:32:05.094289Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444725265}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-2d3d-7000-8178-411fdb90bc34/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:05.094285Z","updatedAt":"2026-10-08T07:32:05.094289Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"9865268e-0a39-43a0-89d0-251a820ec8a3","sequence":5},"last":{"id":"9865268e-0a39-43a0-89d0-251a820ec8a3","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6d-2d3e-7000-b0e7-f04c53ee070b","clientName":"t3_code"},"emittedAtMs":1791444725268}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."}],"displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."}],"displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"531b0e9e-c96d-4fa5-a6dd-5cc7d4569543","sequence":16},"last":{"id":"531b0e9e-c96d-4fa5-a6dd-5cc7d4569543","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444725509}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","status":"accepted","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","startedNewTurn":true,"disposition":"started"}}} {"type":"expect_outbound","label":"turn/interrupt","frame":{"jsonrpc":"2.0","id":4,"method":"turn/interrupt","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","commandId":"01a11a6d-2f0b-7000-a1d4-359cf54e471a"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","status":"running","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:3"},"emittedAtMs":1791444725522}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"},"emittedAtMs":1791444725523}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:05.510508Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725523}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20},"last":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:05.530992Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725539}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:05.510508Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725523}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20},"last":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:05.530992Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725539}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37},"last":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37}},"item":{"itemId":"ae604001-22b9-479c-a45b-3dcfed45996c","kind":"reminderChild","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:05.638142Z","fallbackText":"Reminder child session","childSessionId":"1f58196a-6bf6-42da-9499-8f53a439e6e1","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-2f65-70b1-bb8f-69b33bb4fc62"}},"emittedAtMs":1791444725646}} {"type":"emit_inbound","label":"response:turn/interrupt","frame":{"jsonrpc":"2.0","id":4,"result":{"commandId":"01a11a6d-2f0b-7000-a1d4-359cf54e471a","status":"accepted","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} {"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:7","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37},"last":{"id":"3962d8d1-4608-4dda-8fdc-39a1804474aa","sequence":50}},"item":{"itemId":"ae604001-22b9-479c-a45b-3dcfed45996c","kind":"reminderChild","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"cancelled","recordedAt":"2026-10-08T07:32:05.727236Z","fallbackText":"Reminder child session","failureReason":"main run cancelled","childSessionId":"1f58196a-6bf6-42da-9499-8f53a439e6e1","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-2f65-70b1-bb8f-69b33bb4fc62"}},"emittedAtMs":1791444725734}} diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 2a07b5715a3a..f14b80bcb505 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -713,6 +713,10 @@ it.effect("reports an unusable host over a merely failing one", () => ), }), fakeProvider(SourceControlProviderKind.make("gitlab"), { + unavailableRequirement: { + missing: "Install glab.", + unauthenticated: "Run glab auth login.", + }, getViewer: () => Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), }), @@ -722,7 +726,10 @@ it.effect("reports an unusable host over a merely failing one", () => const error = yield* Effect.flip(service.list({ state: "open" })); assert.strictEqual(error._tag, "PullRequestUnavailableError"); - assert.strictEqual(error.message.includes("glab"), true); + if (error._tag === "PullRequestUnavailableError") { + assert.strictEqual(error.requirement, "Install glab."); + } + assert.strictEqual(error.message, "Install glab."); }), ); @@ -2303,6 +2310,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", () ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { + listsAcrossMayOmitRepositories: true, listChangeRequestsAcross: () => Effect.succeed({ items: [ @@ -2394,7 +2402,51 @@ it.effect("reads a workspace larger than one search in chunks, and merges them", assert.strictEqual(result.entries.length, 101); }), ); -it.effect("asks on its own for a repository a search answered nothing for", () => +it.effect( + "asks on its own for a repository a search answered nothing for, where searches can omit one", + () => + Effect.gen(function* () { + const separately: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakeProvider(SourceControlProviderKind.make("github"), { + listsAcrossMayOmitRepositories: true, + listChangeRequests: ({ repository }) => { + separately.push(repository); + return repository === "acme/docs" + ? Effect.fail(requestFailed) + : Effect.succeed({ items: [], truncated: false, continues: true }); + }, + listChangeRequestsAcross: () => + Effect.succeed({ + items: [batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z")], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(separately, ["acme/docs"]); + assert.deepStrictEqual(result.errors, [ + { + projectId: "p2" as ProjectId, + projectTitle: "docs", + message: "acme/docs could not be read.", + }, + ]); + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [1], + ); + }), +); +it.effect("believes a search's silence about a repository where searches cover every one", () => Effect.gen(function* () { const separately: string[] = []; const service = yield* makeService({ @@ -2406,9 +2458,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () = fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); - return repository === "acme/docs" - ? Effect.fail(requestFailed) - : Effect.succeed({ items: [], truncated: false, continues: true }); + return Effect.succeed({ items: [], truncated: false, continues: true }); }, listChangeRequestsAcross: () => Effect.succeed({ @@ -2421,14 +2471,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () = const result = yield* service.list({ state: "open" }); - assert.deepStrictEqual(separately, ["acme/docs"]); - assert.deepStrictEqual(result.errors, [ - { - projectId: "p2" as ProjectId, - projectTitle: "docs", - message: "acme/docs could not be read.", - }, - ]); + assert.deepStrictEqual(separately, []); assert.deepStrictEqual( result.entries.map((entry) => entry.number), [1], diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 97264c56d133..b124c61d4a8e 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -18,7 +18,6 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import { PullRequestOperationError, PullRequestUnavailableError, - pullRequestProviderRequirement, resolvePullRequestAuthorFilter, type OrchestrationProjectShell, type ProjectId, @@ -402,36 +401,47 @@ function isProviderUnusable(error: PullRequestProviderError): boolean { return error.reason === "missing-tool" || error.reason === "unauthenticated"; } -function providerDetail(error: PullRequestProviderError): string { - if (!isProviderUnusable(error)) return error.detail; - return ( - pullRequestProviderRequirement( - error.provider, - error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", - ) ?? error.detail - ); +type UnavailableRequirementOf = ( + kind: SourceControlProviderKind, +) => PullRequestProviderApi["unavailableRequirement"]; + +function unavailableRequirement( + requirementOf: UnavailableRequirementOf, + error: PullRequestProviderError, +): string | undefined { + const requirement = requirementOf(error.provider); + if (requirement === undefined) return undefined; + return error.reason === "missing-tool" ? requirement.missing : requirement.unauthenticated; } -function toUnavailableError(error: PullRequestProviderError): PullRequestUnavailableError { - return new PullRequestUnavailableError({ - reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", - provider: error.provider, - cause: error, - }); +function providerDetail( + requirementOf: UnavailableRequirementOf, + error: PullRequestProviderError, +): string { + if (!isProviderUnusable(error)) return error.detail; + return unavailableRequirement(requirementOf, error) ?? error.detail; } -function toPullRequestError( - operation: string, -): (error: PullRequestProviderError) => PullRequestError { - return (error) => - isProviderUnusable(error) - ? toUnavailableError(error) - : new PullRequestOperationError({ - operation, - detail: error.detail, - ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), - cause: error, - }); +function toPullRequestErrorWith( + requirementOf: UnavailableRequirementOf, +): (operation: string) => (error: PullRequestProviderError) => PullRequestError { + return (operation) => (error) => { + if (isProviderUnusable(error)) { + const requirement = unavailableRequirement(requirementOf, error); + return new PullRequestUnavailableError({ + reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", + provider: error.provider, + ...(requirement === undefined ? {} : { requirement }), + cause: error, + }); + } + return new PullRequestOperationError({ + operation, + detail: error.detail, + ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), + cause: error, + }); + }; } function withRateLimitBackoff( @@ -493,6 +503,12 @@ function withRateLimitBackoff( ...(api.mergeMessageRewrite === undefined ? {} : { mergeMessageRewrite: api.mergeMessageRewrite }), + ...(api.unavailableRequirement === undefined + ? {} + : { unavailableRequirement: api.unavailableRequirement }), + ...(api.listsAcrossMayOmitRepositories === undefined + ? {} + : { listsAcrossMayOmitRepositories: api.listsAcrossMayOmitRepositories }), ...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }), ...(api.readsPublicHostWithoutCheckout === undefined ? {} @@ -679,6 +695,9 @@ export const make = Effect.gen(function* () { const stateChanges = yield* PubSub.sliding(64); const pullRequestRefreshes = yield* SubscriptionRef.make(0); const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; + const requirementOf: UnavailableRequirementOf = (kind) => + registry.get(kind)?.unavailableRequirement; + const toPullRequestError = toPullRequestErrorWith(requirementOf); const serverSettings = yield* ServerSettings.ServerSettingsService; const projects = yield* ProjectService.ProjectService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; @@ -1172,9 +1191,9 @@ export const make = Effect.gen(function* () { }; }; - // A repository that has appeared in a host search is known to be indexed there. Empty - // authored/reviewing searches for that same repository are therefore real empty answers, not - // a reason to issue the two-command per-repository fallback again. + // On a host whose read across repositories may omit some, a repository that has appeared in + // one is known to be covered. Empty reads for that same repository are therefore real empty + // answers, not a reason to read it on its own again. const searchVisibleAt = new Map(); const searchVisibilityKey = (host: string, repository: string) => `${host}\n${repository.trim().toLowerCase()}`; @@ -1207,7 +1226,7 @@ export const make = Effect.gen(function* () { actions: capabilities?.actions ?? [], projectCount: projectCounts.get(result.host) ?? 1, configured: result.viewer !== null, - detail: result.error === null ? null : providerDetail(result.error), + detail: result.error === null ? null : providerDetail(requirementOf, result.error), }; }), ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ @@ -1412,18 +1431,18 @@ export const make = Effect.gen(function* () { chunk, (project): Effect.Effect => { const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; - // GitHub does not index every repository for search — a renamed one answers for - // its old name with silence rather than with an error — so a repository the - // search said nothing at all about is read on its own, once, before it is - // believed. Only on its first slice: after that it has a boundary to carry on - // from, and silence past one means the rows are older rather than absent. + // A repository the read said nothing at all about, on a host whose read can omit + // one, is read on its own, once, before it is believed. Only on its first slice: + // after that it has a boundary to carry on from, and silence past one means the + // rows are older rather than absent. const lastVisible = searchVisibleAt.get( searchVisibilityKey(project.host, project.repository), ); const searchIsKnownVisible = - !page.truncated && - lastVisible !== undefined && - now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL); + first.api.listsAcrossMayOmitRepositories !== true || + (!page.truncated && + lastVisible !== undefined && + now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL)); if ( fetched.length === 0 && cursorOf(project) === undefined && @@ -1529,7 +1548,7 @@ export const make = Effect.gen(function* () { host, }) .pipe(Effect.mapError(toPullRequestError("routeIdentity"))); - return { ...identity, host, provider: "github" as const }; + return { ...identity, host, provider: api.kind }; }); const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = Effect.fn( @@ -1539,7 +1558,7 @@ export const make = Effect.gen(function* () { const rejected = () => new PullRequestOperationError({ operation: "routeIdentity", - detail: "The GitHub account could not be verified before starting the operation.", + detail: "The signed-in account could not be verified before starting the operation.", }); const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); const api = registry.get(project.api.kind); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 3902c4fbf097..4cf38890205b 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -59,7 +59,7 @@ import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; import { buildDiffReviewComment, - resolveDiffReviewPosition, + resolveDiffReviewAnchor, type ReviewCommentContext, } from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; @@ -231,6 +231,7 @@ interface DraftAnchor { readonly path: string; readonly oldPath: string | null; readonly position: PullRequestReviewPosition; + readonly startPosition?: PullRequestReviewPosition; readonly range: SelectedLineRange; } @@ -466,6 +467,7 @@ function PullRequestCodeTab({ resolve: hostReview.resolve && detail.viewerPermissions.resolve, }; const canCommentOnLines = review.inlineComment && commit === null; + const rangeComments = hostReview.rangeComment === true; // Agent context is local, so it does not require host comment permission. const canSelectLines = commit === null && (review.inlineComment || onAddToAgentSelection !== undefined); @@ -719,23 +721,22 @@ function PullRequestCodeTab({ if (item.type !== "diff") return; const file = files.find((candidate) => buildFileDiffRenderKey(candidate) === item.id); if (!file) return; - // A range collapses to its last line: only GitHub carries a multi-line comment, and one - // that silently lost its first line on the other hosts would be worse than one line. + // On a host without ranged comments a range collapses to its last line: one that silently + // lost its first line there would be worse than one line. const path = resolveFileDiffPath(file); const previousPath = resolveFileDiffPreviousPath(file); const sourceFile = parsedSlices .flatMap((slice) => (slice?.kind === "files" ? slice.sourceFiles : [])) .find((candidate) => resolveFileDiffPath(candidate) === path); - const side = range.endSide ?? range.side; - const position = - (sourceFile && resolveDiffReviewPosition(sourceFile, range.end, side)) ?? - resolveDiffReviewPosition(file, range.end, side); - if (position === null) return; + const anchor = + (sourceFile && resolveDiffReviewAnchor(sourceFile, range, rangeComments)) ?? + resolveDiffReviewAnchor(file, range, rangeComments); + if (anchor === null) return; setDraft({ fileKey: item.id, path, oldPath: previousPath === path ? null : previousPath, - position, + ...anchor, range, }); }; @@ -970,6 +971,7 @@ function PullRequestCodeTab({ id: nextPendingReviewCommentId(), path: draft.path, ...(draft.oldPath === null ? {} : { oldPath: draft.oldPath }), + ...(draft.startPosition === undefined ? {} : { startPosition: draft.startPosition }), position: draft.position, body, }); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index f715ecef5153..d8a1f51a9334 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1744,6 +1744,7 @@ export function PullRequestDetailPanel({ canComment={ detail.capabilities.review.inlineComment && detail.viewerPermissions.comment } + rangeComments={detail.capabilities.review.rangeComment === true} onAskAboutLines={(comment, request) => addSelectionToAgent({ comment, request }, { keepGuide: true }) } diff --git a/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx b/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx index c9a3e89c7adf..f5fd63f059e9 100644 --- a/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx @@ -26,6 +26,7 @@ export function PullRequestGuideTab({ workspaceRoot, active, canComment, + rangeComments, onAskAboutLines, onAskAboutStop, onFinishReview, @@ -38,6 +39,7 @@ export function PullRequestGuideTab({ workspaceRoot: string; active: boolean; canComment: boolean; + rangeComments: boolean; onAskAboutLines: ((comment: ReviewCommentContext, request: string) => void) | null; onAskAboutStop: | ((stop: PullRequestReviewGuideStop, index: number, question: string) => void) @@ -61,12 +63,14 @@ export function PullRequestGuideTab({ ? null : { canComment, + rangeComments, pending, - onComment: ({ path, oldPath, position, body }) => + onComment: ({ path, oldPath, position, startPosition, body }) => addComment(reviewKey, { id: nextPendingReviewCommentId(), path, ...(oldPath === null ? {} : { oldPath }), + ...(startPosition === undefined ? {} : { startPosition }), position, body, }), diff --git a/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx b/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx index 357c201e5bee..803e32ea3194 100644 --- a/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx +++ b/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx @@ -18,7 +18,7 @@ import { import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { buildDiffReviewComment, - resolveDiffReviewPosition, + resolveDiffReviewAnchor, type ReviewCommentContext, } from "~/reviewCommentContext"; @@ -53,11 +53,14 @@ export interface ExcerptNoteMarker { export interface ExcerptCommenting { readonly canComment: boolean; + /** The host anchors a comment to a range of lines rather than to its last one. */ + readonly rangeComments: boolean; readonly pending: ReadonlyArray; readonly onComment: (comment: { readonly path: string; readonly oldPath: string | null; readonly position: PullRequestReviewPosition; + readonly startPosition?: PullRequestReviewPosition | undefined; readonly body: string; }) => void; readonly onRemove: (commentId: string) => void; @@ -73,6 +76,7 @@ interface ExcerptAnnotation { interface ExcerptDraft { readonly range: SelectedLineRange; readonly position: PullRequestReviewPosition; + readonly startPosition?: PullRequestReviewPosition; } type ExcerptSlice = NonNullable>; @@ -253,9 +257,9 @@ function ExcerptDiff({ const beginComment = (range: SelectedLineRange | null) => { if (range === null) return; - const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); - if (position === null) return; - setDraft({ range, position }); + const anchor = resolveDiffReviewAnchor(file, range, commenting?.rangeComments === true); + if (anchor === null) return; + setDraft({ range, ...anchor }); }; const interactive = commenting !== null && draft === null; const onAskAgent = commenting?.onAskAgent ?? null; @@ -344,6 +348,7 @@ function ExcerptDiff({ path: excerpt.path, oldPath: previousPathOf(file, excerpt.path), position: draft.position, + startPosition: draft.startPosition, body, }); setDraft(null); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 184b66256dce..2828d141630a 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -413,7 +413,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting settingKeys={["removeAgentCreditsOnMerge"]} mixed={mixedAgentCredits} {...searchableSetting("remove-agent-credits-on-merge")} - description="Remove recognized agent credit lines from GitHub merge and squash messages, keeping human co-authors. Includes auto-merge. Excludes merge queues, stack merges, and existing commits." + description="Remove recognized agent credit lines from merge and squash messages on hosts that support it, keeping human co-authors. Includes auto-merge. Excludes merge queues, stack merges, and existing commits." resetAction={ settings.removeAgentCreditsOnMerge ? ( { oldLine: 25, }); }); + + it("keeps a selection's first line only for a ranged host and within one hunk", () => { + const fileDiff = parsePatchFiles( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,3 +1,4 @@", + " one", + "+two", + " three", + " four", + "@@ -20,2 +21,3 @@", + " twenty", + "+twenty-one", + " twenty-two", + ].join("\n"), + "review-anchor-range", + )[0]!.files[0]!; + const upward = { start: 3, side: "additions", end: 1, endSide: "additions" } as const; + + expect(resolveDiffReviewAnchor(fileDiff, upward, false)).toEqual({ + position: { kind: "context", oldLine: 1, newLine: 1, side: "right" }, + }); + expect(resolveDiffReviewAnchor(fileDiff, upward, true)).toEqual({ + startPosition: { kind: "context", oldLine: 1, newLine: 1, side: "right" }, + position: { kind: "context", oldLine: 2, newLine: 3, side: "right" }, + }); + expect( + resolveDiffReviewAnchor(fileDiff, { start: 2, side: "additions", end: 22 }, true), + ).toEqual({ position: { kind: "added", newLine: 22 } }); + }); }); diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index e363c3656a73..336a506a5267 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -375,6 +375,46 @@ export function resolveDiffReviewPosition( } } +function reviewHunkIndex(fileDiff: FileDiffMetadata, position: PullRequestReviewPosition): number { + const [side, line] = + position.kind === "deleted" + ? (["left", position.oldLine] as const) + : (["right", position.newLine] as const); + return fileDiff.hunks.findIndex((hunk) => + side === "left" + ? line >= hunk.deletionStart && line < hunk.deletionStart + hunk.deletionCount + : line >= hunk.additionStart && line < hunk.additionStart + hunk.additionCount, + ); +} + +/** + * Where a review comment on `range` is anchored. With `ranged`, a selection inside one hunk + * keeps its first line, in diff order, as `startPosition`, because hosts refuse a range that + * crosses hunks. Otherwise the comment sits on the line the selection ended on. + */ +export function resolveDiffReviewAnchor( + fileDiff: FileDiffMetadata, + range: SelectedLineRange, + ranged: boolean, +): { + readonly position: PullRequestReviewPosition; + readonly startPosition?: PullRequestReviewPosition; +} | null { + const endSide = range.endSide ?? range.side; + const last = resolveDiffReviewPosition(fileDiff, range.end, endSide); + if (last === null) return null; + const first = ranged ? resolveDiffReviewPosition(fileDiff, range.start, range.side) : null; + if (first === null) return { position: last }; + const startIndex = findDiffReviewLineIndex(fileDiff, range.start, range.side); + const endIndex = findDiffReviewLineIndex(fileDiff, range.end, endSide); + if (startIndex === endIndex) return { position: last }; + const [start, end] = startIndex < endIndex ? [first, last] : [last, first]; + const hunk = reviewHunkIndex(fileDiff, end); + return hunk >= 0 && reviewHunkIndex(fileDiff, start) === hunk + ? { position: end, startPosition: start } + : { position: last }; +} + function getDiffRange( lines: ReadonlyArray, key: "oldLineNumber" | "newLineNumber", diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 149bcf57dff0..88b2affa061c 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -89,7 +89,8 @@ Thread settlement is server-owned. The and inactivity settings without a connected client. Merge notifications invalidate cached PR state and trigger a check. A merge outside T3, such as an agent running `gh pr merge`, sends no notification, so the [PR sync reactor](../../apps/server/src/orchestration-v2/PullRequestSyncReactor.ts) -re-reads a thread's open links when a run that ran a merge or close command ends. The guarded +re-reads a thread's open links when a run that ran a merge or close command ends. Each forge's +client definition names its CLI's commands in `changeRequestCloseCommand`. The guarded `thread.auto-settle` command rejects newer activity, explicit settlement overrides, and live or blocked work. It records the activity timestamp for stable sorting and detaches idle provider sessions. Clients render the persisted result; they do not diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index 2eeb6b0f9355..a6cd89ee8643 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -354,7 +354,8 @@ export function createPullRequestRouter() { identityProbe, ) : identityProbe; - if (identity === null || identity.provider !== "github") return yield* finish(source); + // The source server answers only for a host whose account it can verify. + if (identity === null) return yield* finish(source); const routedInput = { ...input, @@ -437,7 +438,7 @@ export function createPullRequestRouter() { ); if ( alternate === null || - alternate.provider !== "github" || + alternate.provider !== identity.provider || alternate.host.toLowerCase() !== identity.host.toLowerCase() || alternate.accountId !== identity.accountId ) { diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 7e371e867722..e7bfa2c35e56 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -240,6 +240,11 @@ export type PullRequestCommit = typeof PullRequestCommit.Type; export const PullRequestReviewCapabilities = Schema.Struct({ inlineComment: Schema.Boolean, + /** + * An inline comment can cover a range of lines rather than its last one. Absent, as from a + * server that predates it, means one line. + */ + rangeComment: Schema.optional(Schema.Boolean), reply: Schema.Boolean, resolve: Schema.Boolean, verdicts: ForwardCompatibleArray(PullRequestReviewVerdict), @@ -281,6 +286,11 @@ export const PullRequestCapabilities = Schema.Struct({ stacks: Schema.optional(Schema.Boolean), stackActions: Schema.optional(Schema.Boolean), labels: Schema.optional(Schema.Boolean), + /** + * Every head gets at least one check, so a head that has gone a while without any means none + * reported rather than that the repository runs none. Absent means an empty list says nothing. + */ + checksAlwaysReport: Schema.optional(Schema.Boolean), }); export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; @@ -421,7 +431,7 @@ export type PullRequestRoutingIdentityInput = typeof PullRequestRoutingIdentityI export const PullRequestRoutingIdentityResult = Schema.Struct({ accountId: TrimmedNonEmptyString, host: TrimmedNonEmptyString, - provider: Schema.Literal("github"), + provider: SourceControlProviderKind, viewer: TrimmedNonEmptyString, }); export type PullRequestRoutingIdentityResult = typeof PullRequestRoutingIdentityResult.Type; @@ -815,6 +825,11 @@ export const PullRequestReviewCommentDraft = Schema.Struct({ */ oldPath: Schema.optional(TrimmedNonEmptyString), position: PullRequestReviewPosition, + /** + * The first line of a comment on a range, which ends at `position`. Sent only to a host whose + * capabilities include `review.rangeComment`. + */ + startPosition: Schema.optional(PullRequestReviewPosition), body: CommentBody, }); export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type; @@ -878,6 +893,10 @@ export const PullRequestLabelChangeInput = Schema.Struct({ }); export type PullRequestLabelChangeInput = typeof PullRequestLabelChangeInput.Type; +/** + * The names keep their `cli-` prefix for wire compatibility; for a host read with a credential + * rather than a tool they mean "no credential" and "a refused credential". + */ export const PullRequestUnavailableReason = Schema.Literals([ "cli-missing", "cli-unauthenticated", @@ -885,59 +904,6 @@ export const PullRequestUnavailableReason = Schema.Literals([ ]); export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.Type; -/** - * The reason names keep their `cli-` prefix for wire compatibility; for GitHub and - * Bitbucket they mean "no credential" and "a refused credential", not a missing tool. - */ -const PROVIDER_REQUIREMENT = new Map< - string, - { readonly missing: string; readonly unauthenticated: string } ->([ - [ - "github", - { - missing: - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.", - unauthenticated: - "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.", - }, - ], - [ - "forgejo", - { - missing: - "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.", - unauthenticated: - "Authenticate your Forgejo or Gitea server with `fj --host auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.", - }, - ], - [ - "gitlab", - { - missing: - "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.", - unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - }, - ], - [ - "azure-devops", - { - missing: - "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.", - unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.", - }, - ], - [ - "bitbucket", - { - missing: - "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", - unauthenticated: - "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", - }, - ], -]); - /** * `author:me` names whoever is signed in to the host being read, GitHub's `@me` spelled either * way. A host's own search would answer it, but the page and the server both re-check an author @@ -953,27 +919,16 @@ export function resolvePullRequestAuthorFilter( return viewer === null || viewer === undefined || viewer.trim().length === 0 ? trimmed : viewer; } -export function pullRequestProviderRequirement( - provider: SourceControlProviderKind, - reason: PullRequestUnavailableReason, -): string | null { - const requirement = PROVIDER_REQUIREMENT.get(provider); - if (requirement === undefined) return null; - switch (reason) { - case "cli-missing": - return requirement.missing; - case "cli-unauthenticated": - return requirement.unauthenticated; - case "provider-unsupported": - return null; - } -} - export class PullRequestUnavailableError extends Schema.TaggedError()( "PullRequestUnavailableError", { reason: PullRequestUnavailableReason, provider: Schema.optional(SourceControlProviderKind), + /** + * What the host needs on the server, in its own words. Absent from older servers, which + * shipped no such field, and for a host that names nothing. + */ + requirement: Schema.optional(TrimmedNonEmptyString), cause: Schema.optional(Schema.Defect()), }, { httpApiStatus: 503 }, @@ -983,15 +938,11 @@ export class PullRequestUnavailableError extends Schema.TaggedError -When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. +When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. `; /** diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index e0753bc82f08..1f63cf9fe95a 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -98,6 +98,9 @@ export const definition = defineSourceControlClient({ "disable-auto-merge", ] as const), checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`, + // Completing or abandoning is a status change, the same `update` this package runs. + changeRequestCloseCommand: + /\baz\s+repos\s+pr\s+update\b[^;&|]*--status[\s=]+["']?(?:completed|abandoned)\b/u, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index ce872c7c1650..5032479d3b42 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -70,6 +70,12 @@ const CAPABILITIES: PullRequestCapabilities = { viewedFiles: "environment", }; +const UNAVAILABLE_REQUIREMENT = { + missing: + "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.", + unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.", +}; + /** * Azure DevOps states no permission anywhere `az repos pr show` or `az repos pr list` reach: the * answer lives in the security namespaces, behind identity descriptors and token paths that would @@ -259,6 +265,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("azure-devops"), capabilities: CAPABILITIES, + unavailableRequirement: UNAVAILABLE_REQUIREMENT, // The bare repository name repeats across an organization; the project path does not. repositoryKey: ({ canonicalKey }) => canonicalKey, diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index ac7b947f1714..c6ca1d155929 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -38,6 +38,12 @@ const CAPABILITIES: PullRequestCapabilities = { viewedFiles: "environment", }; +const UNAVAILABLE_REQUIREMENT = { + missing: "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", + unauthenticated: + "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", +}; + /** * Bitbucket lets the author of a pull request decline their own with no more than read access, * and the permission response says nothing about who opened this one — so withholding the @@ -147,6 +153,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("bitbucket"), capabilities: CAPABILITIES, + unavailableRequirement: UNAVAILABLE_REQUIREMENT, readsPublicHostWithoutCheckout: true, getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index a253780a6a3d..2796f24df062 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -111,6 +111,11 @@ export interface SourceControlClientDefinition { */ readonly changeRequestActions: ReadonlySet; readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null; + /** + * Matches a shell command that merges or closes a change request with this host's CLI, which + * sends T3 no notification of its own. Absent for a host with no such CLI. + */ + readonly changeRequestCloseCommand?: RegExp; readonly authorProfileUrl: (login: string, repositoryUrl: string) => string | null; readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean; diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 18fb90a3b3bf..bd6b9ea1cef6 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -287,6 +287,14 @@ export interface PullRequestProviderApi { >; readonly kind: SourceControlProviderKind; readonly capabilities: PullRequestCapabilities; + /** + * What a reader is told when this host cannot be read on the server: `missing` for no tool or + * credential, `unauthenticated` for one the host refused. Absent means a generic sentence. + */ + readonly unavailableRequirement?: { + readonly missing: string; + readonly unauthenticated: string; + }; readonly mergeMessageRewrite?: (message: string) => string; /** * Absent means `owner/name` on the host identifies it, and any checkout on the host can serve a @@ -335,6 +343,12 @@ export interface PullRequestProviderApi { * rows a page ordered by update shows. `cursor` is one boundary for all of them, so a caller * with repositories standing at different boundaries asks in groups rather than in one call. */ + /** + * Set when `listChangeRequestsAcross` can answer with silence for a repository it does not + * cover rather than with an error, so a repository the read said nothing about is read on its + * own before it is believed empty. + */ + readonly listsAcrossMayOmitRepositories?: boolean; readonly listChangeRequestsAcross?: (input: { readonly cwd: string; readonly host: string; diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index f11e7f58aaa2..d6c8698f591b 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -81,6 +81,7 @@ export const definition = defineSourceControlClient({ repositoryUrl ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' ${changeRequestHeadRef(number)} && git checkout -B pulls/${number} FETCH_HEAD` : null, + changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls)|fj\s+pr)\s+(?:merge|close)\b/u, authorProfileUrl: () => null, // Forgejo refuses a change request review without a summary, even with inline comments. referenceAutolinkRepositoryUrl: () => null, diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index 2ba5b43a4dd5..30ea7faedb25 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -63,6 +63,13 @@ const ForgejoUserSearch = Schema.Struct({ data: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))), }); +const UNAVAILABLE_REQUIREMENT = { + missing: + "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.", + unauthenticated: + "Authenticate your Forgejo or Gitea server with `fj --host auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.", +}; + const repoPath = (input: ProviderRepositoryRef) => `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) => @@ -259,6 +266,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("forgejo"), capabilities: CAPABILITIES, + unavailableRequirement: UNAVAILABLE_REQUIREMENT, readsPublicHostWithoutCheckout: true, getViewer, listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index f0ea69bdbd2a..50a4b33e51c5 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -69,6 +69,7 @@ export const definition = defineSourceControlClient({ "update-branch", ] as const), checkoutCommand: ({ number }) => `cafe pr checkout ${number}`, + changeRequestCloseCommand: /\bcafe\s+(?:pr|stack)\s+(?:merge|close)\b/u, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index f60f10e72389..ffc58c2dec69 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -112,6 +112,7 @@ export const definition = defineSourceControlClient({ "approve-workflows", ] as const), checkoutCommand: ({ number }) => `gh pr checkout ${number}`, + changeRequestCloseCommand: /\bgh\s+(?:pr\s+(?:merge|close)|stack\s+merge)\b/u, authorProfileUrl: (login, repositoryUrl) => login.endsWith("[bot]") ? null diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index f57bf3ee2246..5091d58d5909 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -38,6 +38,7 @@ const CAPABILITIES: PullRequestCapabilities = { viewedFiles: "host", review: { inlineComment: true, + rangeComment: true, reply: true, resolve: true, verdicts: ["comment", "approve", "request-changes"], @@ -47,6 +48,16 @@ const CAPABILITIES: PullRequestCapabilities = { stacks: true, stackActions: true, labels: true, + // Statuses and check runs land on the head commit itself and are read whole, so an empty list + // after a push is CI that never started rather than a read that saw nothing. + checksAlwaysReport: true, +}; + +const UNAVAILABLE_REQUIREMENT = { + missing: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.", + unauthenticated: + "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.", }; /** @@ -243,6 +254,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("github"), capabilities: CAPABILITIES, + unavailableRequirement: UNAVAILABLE_REQUIREMENT, readsPublicHostWithoutCheckout: true, mergeMessageRewrite: removeAgentCredits, getRoutingIdentity: (input) => @@ -294,6 +306,9 @@ export const make = Effect.gen(function* () { ), ), + // GitHub does not index every repository for search: a renamed one answers for its old name + // with silence rather than with an error. + listsAcrossMayOmitRepositories: true, listChangeRequestsAcross: (input) => cli .searchPullRequests({ diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index 5a408c98044b..c5edf102cbc0 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -1399,6 +1399,38 @@ describe("review submission payload", () => { ], }); }); + + it("sends a range from its first line, and a one-line range as one line", () => { + const payload = buildReviewSubmission({ + verdict: "comment", + body: "", + comments: [ + { + path: "src/a.ts", + startPosition: { kind: "context", oldLine: 4, newLine: 5, side: "right" }, + position: { kind: "added", newLine: 9 }, + body: "this block", + }, + { + path: "src/b.ts", + startPosition: { kind: "added", newLine: 2 }, + position: { kind: "added", newLine: 2 }, + body: "this line", + }, + ], + }); + expect(payload.comments).toEqual([ + { + path: "src/a.ts", + start_line: 5, + start_side: "RIGHT", + line: 9, + side: "RIGHT", + body: "this block", + }, + { path: "src/b.ts", line: 2, side: "RIGHT", body: "this line" }, + ]); + }); }); describe("decodePullRequestFilesJson", () => { diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 6d9abb6d604e..4f7e26e1b86d 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -309,6 +309,8 @@ const ReviewSubmissionSchema = Schema.Struct({ comments: Schema.Array( Schema.Struct({ path: Schema.String, + start_line: Schema.optional(Schema.Int), + start_side: Schema.optional(Schema.Literals(["LEFT", "RIGHT"])), line: Schema.Int, side: Schema.Literals(["LEFT", "RIGHT"]), body: Schema.String, @@ -346,11 +348,19 @@ export function buildReviewSubmission(input: { return { event: REVIEW_EVENTS[input.verdict], body: input.body, - comments: input.comments.map((comment) => ({ - path: comment.path, - ...gitHubReviewPosition(comment.position), - body: comment.body, - })), + comments: input.comments.map((comment) => { + const end = gitHubReviewPosition(comment.position); + const start = + comment.startPosition === undefined ? null : gitHubReviewPosition(comment.startPosition); + // GitHub refuses a start that is the end itself, so a one-line range is sent as one line. + const ranged = start !== null && (start.line !== end.line || start.side !== end.side); + return { + path: comment.path, + ...(ranged ? { start_line: start.line, start_side: start.side } : {}), + ...end, + body: comment.body, + }; + }), }; } diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index d9262f5abf73..801de8ca5618 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -46,6 +46,7 @@ export const definition = defineSourceControlClient({ "disable-auto-merge", ] as const), checkoutCommand: ({ number }) => `glab mr checkout ${number}`, + changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|close)\b/u, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 297343fb9bef..987834a5e61e 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -41,6 +41,12 @@ const CAPABILITIES: PullRequestCapabilities = { edit: { changeRequest: true, comment: true }, }; +const UNAVAILABLE_REQUIREMENT = { + missing: + "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.", + unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", +}; + /** * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to * the target, so it is not literally the same permission — but GitLab reports nothing narrower, @@ -115,6 +121,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("gitlab"), capabilities: CAPABILITIES, + unavailableRequirement: UNAVAILABLE_REQUIREMENT, getViewer: (input) => cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), From a6009faba02b0c820ddc7fd5c4a22e1804fc3e35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:35 -0700 Subject: [PATCH 07/65] refactor(clients): pull request surfaces ask the forge instead of assuming GitHub (#23997) Co-authored-by: Claude Opus 5.5 --- .../features/projects/AddProjectScreen.tsx | 3 +- .../PullRequestDiscussionScreen.tsx | 5 +- .../pull-requests/PullRequestMarkdown.tsx | 30 +++++-- .../pull-requests/PullRequestRouteScreen.tsx | 5 +- .../pull-requests/PullRequestStatusRows.tsx | 5 +- .../guide/PullRequestGuideScreen.tsx | 5 +- .../pullRequestActivityFilter.test.ts | 2 +- .../pullRequestActivityFilter.ts | 2 +- .../pullRequestAutolinks.test.ts | 38 ++++++++ .../pull-requests/pullRequestAutolinks.ts | 73 +++++++++++++++ .../pull-requests/usePullRequestList.ts | 7 +- .../src/features/threads/ThreadFeed.tsx | 2 - apps/mobile/src/lib/markdownLinkIcons.ts | 14 +-- apps/mobile/src/lib/markdownLinks.ts | 8 -- .../src/native/useEnrichedLinkVariants.ts | 6 +- apps/web/src/components/ChatMarkdown.tsx | 4 +- apps/web/src/components/CommandPalette.tsx | 17 +++- .../src/components/ThreadStatusIndicators.tsx | 6 +- .../components/chat/MarkdownExternalLink.tsx | 16 ++-- .../pullRequest/PullRequestDetailActions.tsx | 2 +- .../pullRequest/PullRequestDetailPanel.tsx | 12 +-- .../pullRequest/PullRequestMarkdown.tsx | 13 +-- .../pullRequestDetail.logic.test.ts | 16 ++++ .../pullRequest/pullRequestDetail.logic.ts | 9 +- .../pullRequest/pullRequestList.logic.test.ts | 8 +- .../pullRequest/pullRequestList.logic.ts | 9 +- .../pullRequestMarkdown.logic.test.ts | 46 ++++++++++ .../pullRequest/pullRequestMarkdown.logic.ts | 82 ++++++++--------- .../settings/ThemeSearchSection.tsx | 17 ++-- apps/web/src/vendor/mdast-find-and-replace.ts | 89 ------------------- .../client-runtime/src/operations/projects.ts | 20 +++-- .../src/pullRequestMarkdown.test.ts | 19 +++- .../client-runtime/src/pullRequestMarkdown.ts | 18 +++- .../src/sourceControlClients.test.ts | 15 ++++ .../src/sourceControlClients.ts | 1 + packages/shared/package.json | 4 - .../shared/src/githubMarkdownLinks.test.ts | 27 ------ packages/shared/src/githubMarkdownLinks.ts | 89 ------------------- .../src/client/definition.ts | 1 - .../src/client/definition.ts | 1 - .../src/client/definition.ts | 47 +++++++++- .../src/client/definition.ts | 1 - .../src/client/definition.ts | 1 - .../src/server/gitCafePullRequestJson.test.ts | 19 ++++ .../src/server/gitCafePullRequestJson.ts | 7 +- .../src/client/definition.ts | 8 +- .../src/client/referenceAutolinks.test.ts | 44 +++++++++ .../src/client/referenceAutolinks.ts | 58 ++++++++++++ .../src/client/definition.ts | 1 - 49 files changed, 578 insertions(+), 354 deletions(-) create mode 100644 apps/mobile/src/features/pull-requests/pullRequestAutolinks.test.ts create mode 100644 apps/mobile/src/features/pull-requests/pullRequestAutolinks.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts delete mode 100644 apps/web/src/vendor/mdast-find-and-replace.ts delete mode 100644 packages/shared/src/githubMarkdownLinks.test.ts delete mode 100644 packages/shared/src/githubMarkdownLinks.ts create mode 100644 packages/source-control-github/src/client/referenceAutolinks.test.ts create mode 100644 packages/source-control-github/src/client/referenceAutolinks.ts diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 66a68b2ebae2..9ac01547d035 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -21,6 +21,7 @@ import { getNewProjectPublishTarget, getNewProjectPublishTargets, getNewProjectRepository, + defaultHostCloneUrl, normalizePastedCloneUrl, parseAddProjectRemoteSource, resolveAddProjectPath, @@ -821,7 +822,7 @@ export function AddProjectRepositoryScreen(props: { autoCorrect={false} placeholder={ source === "url" - ? "https://github.com/org/repo.git" + ? (defaultHostCloneUrl("org/repo") ?? "https://example.com/org/repo.git") : addProjectRemoteSourcePathHint(source) } returnKeyType="next" diff --git a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx index 6ee6c572625f..68ab91b3a3f9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx @@ -124,10 +124,7 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss const canReply = permissions.canReply && Boolean(thread); const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve"; return ( - + (null); +const AutolinksContext = createContext<((text: string) => ReadonlyArray) | null>( + null, +); export function PullRequestMarkdownRepository(props: { - readonly value: string | null; + /** The change request the markdown belongs to, whose host decides what its text links. */ + readonly changeRequest: { readonly provider: string; readonly url: string } | null | undefined; readonly params: PullRequestParams; readonly children: ReactNode; }) { + const provider = props.changeRequest?.provider; + const url = props.changeRequest?.url; + const autolinks = useMemo( + () => + provider === undefined || url === undefined + ? null + : changeRequestReferenceAutolinker({ provider, url }), + [provider, url], + ); const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, @@ -44,9 +58,9 @@ export function PullRequestMarkdownRepository(props: { [target.environmentId, cwd], ); return ( - + {props.children} - + ); } @@ -103,11 +117,11 @@ function PullRequestMarkdownContent(props: { readonly markdown: string; readonly imageGroupMarkdown?: string; }) { - const repositoryUrl = useContext(RepositoryContext); + const autolinks = useContext(AutolinksContext); const mediaContext = useContext(PullRequestMediaContext); const markdown = useMemo( - () => githubMarkdownLinks(props.markdown, repositoryUrl), - [props.markdown, repositoryUrl], + () => linkReferenceAutolinks(props.markdown, autolinks), + [props.markdown, autolinks], ); const openPullRequest = useOpenPullRequest(); const openLink = useCallback( diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index f83ef07eb154..c12bd8739644 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -387,10 +387,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps + void Linking.openURL( - props.pr.provider === "github" ? `${props.pr.url}/checks` : props.pr.url, + sourceControlClients + .get(props.pr.provider) + .changeRequestChecksUrl?.(props.pr.url) ?? props.pr.url, ) } /> diff --git a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx index cfc18131873b..08fdfb1ab18c 100644 --- a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx +++ b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx @@ -161,10 +161,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps - + ({ id, author, diff --git a/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts index 46c4cfda2ac9..85b50b2fcf63 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts @@ -15,7 +15,7 @@ export const DEFAULT_ACTIVITY_FILTER: ActivityFilter = { }; function isBot(actor: PullRequestActor | null) { - return actor?.isBot === true || actor?.login.endsWith("[bot]") === true; + return actor?.isBot === true; } function eventCount(activity: PullRequestActivity) { diff --git a/apps/mobile/src/features/pull-requests/pullRequestAutolinks.test.ts b/apps/mobile/src/features/pull-requests/pullRequestAutolinks.test.ts new file mode 100644 index 000000000000..4aeeb1f388a7 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestAutolinks.test.ts @@ -0,0 +1,38 @@ +import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; +import { describe, expect, it } from "vite-plus/test"; + +import { linkReferenceAutolinks } from "./pullRequestAutolinks"; + +const github = changeRequestReferenceAutolinker({ + provider: "github", + url: "https://github.com/team/repo/pull/8", +}); + +describe("linkReferenceAutolinks", () => { + it("links the host's references in prose", () => { + expect(linkReferenceAutolinks("Closes #123, see @julius", github)).toBe( + "Closes [#123](https://github.com/team/repo/issues/123), see [@julius](https://github.com/julius)", + ); + }); + + it("leaves code, links, images, escapes and definitions alone", () => { + const text = + "`#123 @person abc1234`\n\n```\n#123\n```\n\n[#123](https://example.com) ![#123](image.png) https://example.com/#123 user@example.com \\#123 1234567\n\n[ref]: https://example.com/#123"; + expect(linkReferenceAutolinks(text, github)).toBe(text); + }); + + it("does not nest links inside HTML anchors or code", () => { + const text = 'See #12 and #13, then #14.'; + expect(linkReferenceAutolinks(text, github)).toBe( + 'See #12 and #13, then [#14](https://github.com/team/repo/issues/14).', + ); + }); + + it("leaves markdown unchanged for a host that links nothing", () => { + const gitlab = changeRequestReferenceAutolinker({ + provider: "gitlab", + url: "https://gitlab.com/group/project/-/merge_requests/7", + }); + expect(linkReferenceAutolinks("Closes #123", gitlab)).toBe("Closes #123"); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestAutolinks.ts b/apps/mobile/src/features/pull-requests/pullRequestAutolinks.ts new file mode 100644 index 000000000000..d46b2b11a665 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestAutolinks.ts @@ -0,0 +1,73 @@ +import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +type MarkdownNode = { + readonly type: string; + readonly position?: + | { + readonly start: { readonly offset?: number | undefined }; + readonly end: { readonly offset?: number | undefined }; + } + | undefined; + readonly children?: readonly MarkdownNode[] | undefined; +}; + +const parser = unified().use(remarkParse).use(remarkGfm); +const UNLINKED_TYPES = new Set([ + "link", + "linkReference", + "image", + "imageReference", + "code", + "inlineCode", + "html", + "definition", +]); + +/** + * Rewrites the references `autolinks` finds in the markdown's prose as markdown links, because the + * native renderer takes markdown text. Text is searched as written, escapes included. + */ +export function linkReferenceAutolinks( + markdown: string, + autolinks: ((text: string) => ReadonlyArray) | null, +): string { + if (autolinks === null) return markdown; + const edits: { start: number; end: number; value: string }[] = []; + const visit = (node: MarkdownNode) => { + if (UNLINKED_TYPES.has(node.type)) return; + if (node.type === "text") { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start === undefined || end === undefined) return; + for (const link of autolinks(markdown.slice(start, end))) { + edits.push({ + start: start + link.index, + end: start + link.index + link.text.length, + value: `[${link.label}](${link.url})`, + }); + } + } else if (node.children !== undefined) { + let protectedHtmlDepth = 0; + for (const child of node.children) { + if (child.type === "html") { + const start = child.position?.start.offset; + const end = child.position?.end.offset; + const tag = start === undefined || end === undefined ? "" : markdown.slice(start, end); + for (const match of tag.matchAll(/<(\/)?(?:a|code|pre|script|style)(?=[\s>])[^>]*>/giu)) { + protectedHtmlDepth = Math.max(0, protectedHtmlDepth + (match[1] ? -1 : 1)); + } + } else if (protectedHtmlDepth === 0) visit(child); + } + } + }; + visit(parser.parse(markdown)); + let linked = markdown; + for (let index = edits.length - 1; index >= 0; index--) { + const edit = edits[index]!; + linked = linked.slice(0, edit.start) + edit.value + linked.slice(edit.end); + } + return linked; +} diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index 512e9c46036e..5656e1d18ab5 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -202,12 +202,7 @@ export function usePullRequestList(targets: readonly ListTarget[]) { const statsKey = JSON.stringify( targets.flatMap(({ environmentId }) => { const refs = visibleRows - .filter( - (row) => - row.environmentId === environmentId && - row.provider === "github" && - row.additions + row.deletions === 0, - ) + .filter((row) => row.environmentId === environmentId && row.additions + row.deletions === 0) .map(({ projectId, host, repository, number }) => ({ projectId, host, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f2f4e9a39866..5d29e519917f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -188,11 +188,9 @@ import { } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; -import { markdownLinkIconSource } from "../../lib/markdownLinkIcons"; import { normalizeNativeMarkdownUrl, resolveMarkdownInlineCodePresentation, - resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation, } from "../../lib/markdownLinks"; import { diff --git a/apps/mobile/src/lib/markdownLinkIcons.ts b/apps/mobile/src/lib/markdownLinkIcons.ts index 13eee7bbfac0..f88ac2f52bca 100644 --- a/apps/mobile/src/lib/markdownLinkIcons.ts +++ b/apps/mobile/src/lib/markdownLinkIcons.ts @@ -1,11 +1,13 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import type { ImageSourcePropType } from "react-native"; -import type { MarkdownLinkIcon } from "./markdownLinks"; - -const MARKDOWN_LINK_ICON_SOURCES = { +// The native renderer draws a link's icon from an image, so a host's mark is a bundled image keyed +// by its definition's `icon`. A host without one keeps a plain link. +const SOURCE_CONTROL_LINK_ICON_SOURCES: Partial> = { github: require("../../assets/link-icons/github.png"), -} as const satisfies Readonly>; +}; -export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType { - return MARKDOWN_LINK_ICON_SOURCES[icon]; +export function markdownLinkIconSource(host: string): ImageSourcePropType | null { + const icon = sourceControlClients.findByPublicDomain(host)?.icon; + return (icon === undefined ? undefined : SOURCE_CONTROL_LINK_ICON_SOURCES[icon]) ?? null; } diff --git a/apps/mobile/src/lib/markdownLinks.ts b/apps/mobile/src/lib/markdownLinks.ts index f68add2bf7f9..8020c1de5b13 100644 --- a/apps/mobile/src/lib/markdownLinks.ts +++ b/apps/mobile/src/lib/markdownLinks.ts @@ -34,14 +34,6 @@ export type MarkdownLinkPresentation = export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; -export type MarkdownLinkIcon = "github"; - -export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null { - const hostname = host.toLowerCase(); - if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github"; - return null; -} - const FILE_ICON_BY_NAME: Readonly> = { ".babelrc": "babel", ".babelrc.json": "babel", diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index 2b32956f5e03..dda01bd75451 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -13,7 +13,7 @@ import { contextChipIconSource } from "../lib/contextChipIconSource"; import { markdownFileIconSource } from "../lib/markdownFileIcons"; import { markdownLinkIconSource } from "../lib/markdownLinkIcons"; import { useUniwindTheme } from "../lib/useUniwindTheme"; -import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; +import { resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; import type { MarkdownLinkCustomization, SelectableMarkdownTextProps, @@ -83,11 +83,11 @@ function presentation( icon: markdownFileIconSource(link.icon), chip: { accent: contextChipPresentation("mention").accent, tintsIcon: false }, }; - const icon = link.kind === "external" ? resolveMarkdownLinkIcon(link.host) : null; + const icon = link.kind === "external" ? markdownLinkIconSource(link.host) : null; return icon ? { color: props.textStyle.linkColor, - icon: markdownLinkIconSource(icon), + icon, iconTintColor: props.textStyle.linkColor, } : undefined; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2a3c71583427..847d5dcd8d53 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1102,8 +1102,8 @@ const CHAT_MARKDOWN_COMPONENTS = { ); const pullRequestCopy = pullRequestAutolink === "commit" - ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1] - : pullRequestAutolink === "reference" + ? /\/commit\/([0-9a-f]{7,40})$/iu.exec(href ?? "")?.[1] + : pullRequestAutolink === "reference" || pullRequestAutolink === "mention" ? plainHastText(node) : undefined; const isPullRequestAutolink = pullRequestCopy !== undefined; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 53a39007574e..0d760535ee70 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1540,7 +1540,22 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:pull-requests", - searchTerms: ["pull requests", "prs", "pr", "github", "review", "merge", "branch"], + searchTerms: [ + ...new Set( + sourceControlClients.definitions.flatMap((definition) => { + const shortLabel = definition.changeRequest.shortLabel.toLowerCase(); + return [ + `${definition.changeRequest.singular}s`, + `${shortLabel}s`, + shortLabel, + ...definition.label.toLowerCase().split(/[^a-z0-9]+/u), + ].filter(Boolean); + }), + ), + "review", + "merge", + "branch", + ], title: "Open pull requests", icon: , run: async () => { diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 7a66512ccadc..d540cc8e06f6 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -114,7 +114,11 @@ export function useLinkedThreadPullRequest( ? null : { pr: pullRequestDetailToVcsStatus(detail), - sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" }, + sourceControlProvider: { + kind: detail.provider, + name: sourceControlClients.get(detail.provider).label, + baseUrl: "", + }, }; } diff --git a/apps/web/src/components/chat/MarkdownExternalLink.tsx b/apps/web/src/components/chat/MarkdownExternalLink.tsx index fae6a9fad486..b3efae29406b 100644 --- a/apps/web/src/components/chat/MarkdownExternalLink.tsx +++ b/apps/web/src/components/chat/MarkdownExternalLink.tsx @@ -2,21 +2,25 @@ import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { GlobeIcon } from "lucide-react"; import { Children, Fragment, memo, useState, type ReactNode } from "react"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; + import { cn } from "../../lib/utils"; -import { GitHubIcon } from "../Icons"; +import { sourceControlIcon } from "../../sourceControlPresentation"; const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; const failedFaviconHosts = new Set(); -function isGitHubHost(host: string): boolean { - const hostname = host.toLowerCase(); - return hostname === "github.com" || hostname.endsWith(".github.com"); +function sourceControlHostIcon(host: string): ReactNode { + const definition = sourceControlClients.findByPublicDomain(host); + if (definition === undefined) return null; + const Icon = sourceControlIcon(definition); + return ; } const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); - const brandIcon = isGitHubHost(host); + const brandIcon = sourceControlHostIcon(host); const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); return ( {brandIcon ? ( - + brandIcon ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx index de1af5536fb6..c4956c019962 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx @@ -131,7 +131,7 @@ export function PullRequestCheckoutMenu({ In this repository - Switches the branch you are working in, like `gh pr checkout`. + Switches the branch you are working in. diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index d8a1f51a9334..385f9c416d61 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,4 +1,5 @@ import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links"; +import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; import { useAtomValue } from "@effect/atom-react"; import { usePullRequestStack } from "~/state/usePullRequestStack"; import { @@ -233,15 +234,10 @@ function resolvePullRequestReference( } function pullRequestMarkdownContext( - hostClient: ReturnType, - repositoryUrl: string | null, + detail: Pick | null, threadRef: ScopedThreadRef | null, ) { - return { - repositoryUrl: - repositoryUrl === null ? null : hostClient.referenceAutolinkRepositoryUrl(repositoryUrl), - threadRef, - }; + return { autolinks: changeRequestReferenceAutolinker(detail), threadRef }; } function pullRequestStackReference( @@ -477,7 +473,7 @@ export function PullRequestDetailPanel({ : (sourceControlClients.parseChangeRequestUrl(detail.url)?.link.repositoryUrl ?? null); const hostClient = sourceControlClients.get(detail?.provider); const openOnHostText = openOnHostLabel(hostClient); - const markdownContext = pullRequestMarkdownContext(hostClient, repositoryUrl, threadRef); + const markdownContext = pullRequestMarkdownContext(detail, threadRef); const authorProfileUrl = detail?.author && repositoryUrl !== null ? hostClient.authorProfileUrl(detail.author.login, repositoryUrl) diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 91dec8acdaca..a9fdf0f09800 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,6 +1,9 @@ import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"; import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + sourceControlClients, + type ReferenceAutolink, +} from "@t3tools/client-runtime/source-control-clients"; import { SOURCE_CONTROL_MEDIA_ASSET, type AssetResource, @@ -19,7 +22,7 @@ import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; export const PullRequestMarkdownContext = createContext<{ - repositoryUrl: string | null; + autolinks: ((text: string) => ReadonlyArray) | null; threadRef: ScopedThreadRef | null; } | null>(null); @@ -66,10 +69,10 @@ export function PullRequestMarkdown({ }) { const segments = splitPullRequestBody(text); const context = useContext(PullRequestMarkdownContext); - const repositoryUrl = context?.repositoryUrl; + const autolinks = context?.autolinks; const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined; - const extraRemarkPlugins: NonNullable = repositoryUrl - ? [[remarkPullRequestAutolinks, { repositoryUrl }]] + const extraRemarkPlugins: NonNullable = autolinks + ? [[remarkPullRequestAutolinks, { autolinks }]] : []; return (
{ }, ); }); + +describe("readableFailure", () => { + it("replaces a host tool's bare failure with the hint, for every host", () => { + for (const message of [ + "GitHub CLI failed.", + "Azure DevOps API failed", + "Forgejo command failed", + ]) { + expect(readableFailure(new Error(message), "Try again.")).toBe("Try again."); + } + expect(readableFailure(new Error("Branch is protected."), "Try again.")).toBe( + "Branch is protected.", + ); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 9dabaea0992e..8f5446811a0a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -646,8 +646,7 @@ export function filterPullRequestConversationItems( // Matching any member keeps the finding beside a human reply to a bot. pullRequestConversationItemComments(item).some( (comment) => - (filter.bots || - !(comment.author?.isBot === true || comment.author?.login.endsWith("[bot]"))) && + (filter.bots || comment.author?.isBot !== true) && (filter.resolved || !isPullRequestCommentFinished(comment, item.kind === "thread" ? item.thread : undefined)), ), @@ -1216,8 +1215,12 @@ export function isPullRequestNotFound(failure: unknown): boolean { const OPERATION_PREFIX = /^Pull request operation \w+ failed:\s*/iu; +const HOST_LABELS = sourceControlClients.definitions + .map((definition) => definition.label.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")) + .join("|"); + const TOOL_NOISE = [ - /^(github|gitlab|bitbucket|azure devops)?\s*(cli|api)?\s*(command\s*)?failed\.?$/iu, + new RegExp(`^(${HOST_LABELS})?\\s*(cli|api)?\\s*(command\\s*)?failed\\.?$`, "iu"), /^exited? with (code|status) \d+\.?$/iu, /^unknown error\.?$/iu, ]; diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 58f6964e4b9e..7f3c6bfd0bd1 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -701,10 +701,11 @@ describe("pull request quick actions", () => { ...(actions === undefined ? {} : { actions }), }); - it("offers what each host reports, and GitHub's legacy set when a server reports none", () => { + it("offers what each host reports, and the host's own set when a server reports none", () => { const actionsOf = pullRequestHostActions([ summary("bitbucket.org", "bitbucket", ["merge", "close"]), summary("github.com", "github"), + summary("gitlab.com", "gitlab"), ]); const bitbucket = actionsOf({ host: "bitbucket.org", @@ -719,6 +720,11 @@ describe("pull request quick actions", () => { "close", "ready", ]); + const gitlab = actionsOf({ + host: "gitlab.com", + provider: SourceControlProviderKind.make("gitlab"), + }); + expect(pullRequestQuickActions({ state: "open", isDraft: false }, gitlab)).toEqual([]); }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 656913257a11..5bf0b6f4c021 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1015,10 +1015,11 @@ export function pullRequestHostActions( : [[provider.host.toLowerCase(), new Set(provider.actions)] as const], ), ); - const legacy = new Set(["close", "merge", "ready", "reopen"]); const none = new Set(); return (entry) => - byHost.get(entry.host.toLowerCase()) ?? (entry.provider === "github" ? legacy : none); + byHost.get(entry.host.toLowerCase()) ?? + sourceControlClients.find(entry.provider)?.changeRequestActionsWhenUnreported ?? + none; } export function pullRequestQuickActions( @@ -1042,7 +1043,9 @@ export function largestPullRequestReadCount( const counts = new Map(); for (const entry of entries) { const read = `${entry.environmentId} ${entry.host.toLowerCase()} ${ - entry.provider === "github" ? "" : entry.repository.toLowerCase() + sourceControlClients.find(entry.provider)?.changeRequestListScope === "host" + ? "" + : entry.repository.toLowerCase() }`; counts.set(read, (counts.get(read) ?? 0) + 1); } diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts new file mode 100644 index 000000000000..19c2e9038449 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts @@ -0,0 +1,46 @@ +import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { describe, expect, it } from "vite-plus/test"; + +import { remarkPullRequestAutolinks } from "./pullRequestMarkdown.logic"; + +type Node = { + type: string; + value?: string; + url?: string; + data?: { hProperties?: Record }; + children?: Node[]; +}; + +function links(markdown: string) { + const autolinks = changeRequestReferenceAutolinker({ + provider: "github", + url: "https://github.com/team/repo/pull/8", + })!; + const tree = unified().use(remarkParse).parse(markdown) as Node; + remarkPullRequestAutolinks({ autolinks })(tree as never); + const found: Array<[string | undefined, string | undefined, unknown]> = []; + const visit = (node: Node) => { + if (node.type === "link") { + found.push([ + node.children?.[0]?.value, + node.url, + node.data?.hProperties?.["dataPullRequestAutolink"], + ]); + } + node.children?.forEach(visit); + }; + visit(tree); + return found; +} + +describe("remarkPullRequestAutolinks", () => { + it("links the host's references in prose and leaves links and code alone", () => { + expect(links("Fixes #12 in **abcdef1**. [#13](https://example.com) `#14`")).toEqual([ + ["#12", "https://github.com/team/repo/issues/12", "reference"], + ["abcdef1", "https://github.com/team/repo/commit/abcdef1", "commit"], + ["#13", "https://example.com", undefined], + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index 659236fc2e54..bb38f3d6caf9 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -1,52 +1,54 @@ -import { - findAndReplaceText, - type MarkdownNode, - type TextMatch, -} from "~/vendor/mdast-find-and-replace"; +import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; export { splitPullRequestBody, type PullRequestBodySegment, } from "@t3tools/client-runtime/pull-request-markdown"; -const AUTOLINK_CANDIDATE_PATTERN = /#[1-9]\d*|[0-9a-f]{40}/giu; -const AUTOLINK_WORD_CHARACTER_PATTERN = /[A-Za-z0-9_]/u; -const AUTOLINK_COMMIT_PREFIX_PATTERN = /[\s([{]/u; +type MarkdownNode = { + type: string; + value?: string; + children?: MarkdownNode[]; + url?: string; + data?: unknown; +}; + const AUTOLINK_IGNORED_TYPES = new Set(["link", "linkReference"]); -export function remarkPullRequestAutolinks(options: { readonly repositoryUrl: string }) { - const repositoryUrl = options.repositoryUrl.replace(/\/+$/u, ""); - return (tree: MarkdownNode) => { - findAndReplaceText( - tree, - AUTOLINK_CANDIDATE_PATTERN, - (matched: string, match: TextMatch) => { - const reference = matched.startsWith("#"); - const before = match.input[match.index - 1]; - const after = match.input[match.index + matched.length]; - if ( - (before !== undefined && - (reference - ? AUTOLINK_WORD_CHARACTER_PATTERN.test(before) - : !AUTOLINK_COMMIT_PREFIX_PATTERN.test(before))) || - (after !== undefined && AUTOLINK_WORD_CHARACTER_PATTERN.test(after)) - ) { - return false; +export function remarkPullRequestAutolinks(options: { + readonly autolinks: (text: string) => ReadonlyArray; +}) { + const visit = (node: MarkdownNode): void => { + const children = node.children; + if (children === undefined) return; + for (let index = 0; index < children.length; index += 1) { + const child = children[index]!; + if (AUTOLINK_IGNORED_TYPES.has(child.type)) continue; + if (child.type !== "text" || child.value === undefined) { + visit(child); + continue; + } + const value = child.value; + const links = options.autolinks(value); + if (links.length === 0) continue; + const replacements: MarkdownNode[] = []; + let start = 0; + for (const link of links) { + if (start < link.index) { + replacements.push({ type: "text", value: value.slice(start, link.index) }); } - return { + replacements.push({ type: "link", - url: reference - ? `${repositoryUrl}/issues/${matched.slice(1)}` - : `${repositoryUrl}/commit/${matched}`, - data: { - hProperties: { - dataPullRequestAutolink: reference ? "reference" : "commit", - }, - }, - children: [{ type: "text", value: reference ? matched : matched.slice(0, 7) }], - }; - }, - AUTOLINK_IGNORED_TYPES, - ); + url: link.url, + data: { hProperties: { dataPullRequestAutolink: link.kind } }, + children: [{ type: "text", value: link.label }], + }); + start = link.index + link.text.length; + } + if (start < value.length) replacements.push({ type: "text", value: value.slice(start) }); + children.splice(index, 1, ...replacements); + index += replacements.length - 1; + } }; + return (tree: MarkdownNode) => visit(tree); } diff --git a/apps/web/src/components/settings/ThemeSearchSection.tsx b/apps/web/src/components/settings/ThemeSearchSection.tsx index b754391c459b..0afa8cff9afe 100644 --- a/apps/web/src/components/settings/ThemeSearchSection.tsx +++ b/apps/web/src/components/settings/ThemeSearchSection.tsx @@ -1,6 +1,7 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; import { importOpenVsxThemeExtension, searchOpenVsxThemes, @@ -14,7 +15,7 @@ import { replaceCustomThemeCollection, type ThemeDefinition, } from "../../themePalette"; -import { GitHubIcon, GitLabIcon } from "../Icons"; +import { sourceControlIcon } from "../../sourceControlPresentation"; import { AlertDialog, AlertDialogClose, @@ -51,13 +52,11 @@ function sourceHost(url: string): string | null { } } -function SourceLinkIcon({ url }: { url: string }) { +function sourceLinkIcon(url: string): ReactNode { const host = sourceHost(url); - if (host === "github.com" || host?.endsWith(".github.com")) - return ; - if (host === "gitlab.com" || host?.endsWith(".gitlab.com")) - return ; - return ; + const definition = host === null ? undefined : sourceControlClients.findByPublicDomain(host); + const Icon = definition ? sourceControlIcon(definition) : ExternalLinkIcon; + return ; } function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) { @@ -415,7 +414,7 @@ export function ThemeSearchSection({ size="icon-micro" variant="ghost-muted" > - + {sourceLinkIcon(extension.sourceUrl)} ) : null}
diff --git a/apps/web/src/vendor/mdast-find-and-replace.ts b/apps/web/src/vendor/mdast-find-and-replace.ts deleted file mode 100644 index f1bd1cbdfee3..000000000000 --- a/apps/web/src/vendor/mdast-find-and-replace.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Adapted from mdast-util-find-and-replace: - * https://github.com/syntax-tree/mdast-util-find-and-replace/blob/main/lib/index.js - * - * The MIT License - * - * Copyright (c) Titus Wormer - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -export type MarkdownNode = { - type: string; - value?: string; - children?: MarkdownNode[]; - url?: string; - data?: unknown; -}; - -export type TextMatch = { - index: number; - input: string; -}; - -export function findAndReplaceText( - tree: MarkdownNode, - find: RegExp, - replace: (matched: string, match: TextMatch) => MarkdownNode | false, - ignoredTypes: ReadonlySet, -): void { - visit(tree); - - function visit(node: MarkdownNode): void { - if (node.children === undefined) return; - for (let childIndex = 0; childIndex < node.children.length; childIndex += 1) { - const child = node.children[childIndex]!; - if (ignoredTypes.has(child.type)) continue; - if (child.type !== "text" || child.value === undefined) { - visit(child); - continue; - } - - const replacements: MarkdownNode[] = []; - let start = 0; - let changed = false; - find.lastIndex = 0; - let match = find.exec(child.value); - while (match !== null) { - const position = match.index; - const replacement = replace(match[0], { index: position, input: match.input }); - if (replacement === false) { - find.lastIndex = position + 1; - } else { - if (start < position) { - replacements.push({ type: "text", value: child.value.slice(start, position) }); - } - replacements.push(replacement); - start = position + match[0].length; - changed = true; - } - if (!find.global) break; - match = find.exec(child.value); - } - - if (!changed) continue; - if (start < child.value.length) { - replacements.push({ type: "text", value: child.value.slice(start) }); - } - node.children.splice(childIndex, 1, ...replacements); - childIndex += replacements.length - 1; - } - } -} diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 90729a640465..182c95d6d717 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -91,14 +91,24 @@ export function parseAddProjectRemoteSource( return (value ? sourceControlClients.find(value)?.kind : undefined) ?? "url"; } -const GITHUB_REPOSITORY_SHORTHAND = - /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/; +const REPOSITORY_SHORTHAND = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/; + +/** + * The clone URL of `owner/name` on the public host of the host a repository reads as before it + * reports one. Null when that host has no public host. + */ +export function defaultHostCloneUrl(repository: string): string | null { + const definition = sourceControlClients.get(undefined); + if (definition.publicHost === null) return null; + const path = repository.endsWith(".git") ? repository : `${repository}.git`; + return definition.defaultCloneTransport === "https" + ? `https://${definition.publicHost}/${path}` + : `git@${definition.publicHost}:${path}`; +} export function normalizePastedCloneUrl(input: string): string { const trimmed = input.trim(); - if (!GITHUB_REPOSITORY_SHORTHAND.test(trimmed)) return trimmed; - const repository = trimmed.endsWith(".git") ? trimmed : `${trimmed}.git`; - return `https://github.com/${repository}`; + return REPOSITORY_SHORTHAND.test(trimmed) ? (defaultHostCloneUrl(trimmed) ?? trimmed) : trimmed; } export function getDefaultCloneUrl( diff --git a/packages/client-runtime/src/pullRequestMarkdown.test.ts b/packages/client-runtime/src/pullRequestMarkdown.test.ts index 5c0b3d84f27b..3395d78313b9 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.test.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { splitPullRequestBody } from "./pullRequestMarkdown.ts"; +import { changeRequestReferenceAutolinker, splitPullRequestBody } from "./pullRequestMarkdown.ts"; describe("pull request body segmentation", () => { // GitHub writes a dropped image into the body as an `` tag, so a bare attachment link on @@ -123,3 +123,20 @@ describe("media on custom hosts", () => { ]); }); }); + +describe("change request reference autolinks", () => { + const urls = (provider: string, url: string, text: string) => + changeRequestReferenceAutolinker({ provider, url })?.(text).map((link) => link.url) ?? null; + + it("links references against the change request's own repository", () => { + expect(urls("github", "https://ghe.example.com/acme/web/pull/7", "Fixes #5")).toEqual([ + "https://ghe.example.com/acme/web/issues/5", + ]); + }); + + // regression: the mobile review guide linked every host's `#5` with GitHub's grammar + it("links nothing on a host whose grammar is not applied", () => { + expect(urls("gitlab", "https://gitlab.com/group/project/-/merge_requests/7", "#5")).toBeNull(); + expect(urls("forgejo", "https://codeberg.org/acme/web/pulls/7", "#5")).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/pullRequestMarkdown.ts b/packages/client-runtime/src/pullRequestMarkdown.ts index 8e342c425dba..1e03c32509e9 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.ts @@ -1,4 +1,4 @@ -import { sourceControlClients } from "./sourceControlClients.ts"; +import { sourceControlClients, type ReferenceAutolink } from "./sourceControlClients.ts"; /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ export type PullRequestBodySegment = @@ -131,3 +131,19 @@ export function splitPullRequestBody(body: string): ReadonlyArray ReadonlyArray) | null { + if (!changeRequest) return null; + const find = sourceControlClients.get(changeRequest.provider).referenceAutolinks; + const repositoryUrl = sourceControlClients.parseChangeRequestUrl(changeRequest.url)?.link + .repositoryUrl; + return find === undefined || repositoryUrl === undefined + ? null + : (text) => find(text, repositoryUrl); +} diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts index 9a2cb20e4a47..a47b6ddfe53f 100644 --- a/packages/client-runtime/src/sourceControlClients.test.ts +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -350,3 +350,18 @@ describe("sourceControlClients project hosts", () => { expect(sourceControlClients.repositorySelector({ owner: "o", name: "r" })).toBe("o/r"); }); }); + +describe("sourceControlClients.findByPublicDomain", () => { + it("names the host a link is on, subdomains included", () => { + const kindOf = (hostname: string) => sourceControlClients.findByPublicDomain(hostname)?.kind; + expect(kindOf("gist.github.com")).toBe("github"); + expect(kindOf("GitLab.com")).toBe("gitlab"); + expect(kindOf("codeberg.org")).toBe("forgejo"); + expect(kindOf("notgithub.com")).toBeUndefined(); + expect(kindOf("github.com.example")).toBeUndefined(); + }); + + it("leaves findByPublicHost to the public host alone", () => { + expect(sourceControlClients.findByPublicHost("gist.github.com")).toBeUndefined(); + }); +}); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index ac67c682c520..87da337cdee8 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -11,6 +11,7 @@ export { type ChangeRequestLink, type ChangeRequestTerminology, type ParsedChangeRequestUrl, + type ReferenceAutolink, type SourceControlClientDefinition, } from "@t3tools/source-control-core/client/definition"; import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; diff --git a/packages/shared/package.json b/packages/shared/package.json index 0fcf52cc8805..81f26b66384b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,10 +7,6 @@ "types": "./src/changedFileKind.ts", "import": "./src/changedFileKind.ts" }, - "./githubMarkdownLinks": { - "types": "./src/githubMarkdownLinks.ts", - "import": "./src/githubMarkdownLinks.ts" - }, "./authScopeOptions": { "types": "./src/authScopeOptions.ts", "import": "./src/authScopeOptions.ts" diff --git a/packages/shared/src/githubMarkdownLinks.test.ts b/packages/shared/src/githubMarkdownLinks.test.ts deleted file mode 100644 index be31e63603cc..000000000000 --- a/packages/shared/src/githubMarkdownLinks.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { githubMarkdownLinks } from "./githubMarkdownLinks.ts"; - -const url = "https://github.com/team/repo/pull/8"; -describe("githubMarkdownLinks", () => { - it("links closing references, cross-repository references, commits and mentions", () => { - expect( - githubMarkdownLinks( - "Closes #123, fixes other/project#9; other#4 @julius @team/mobile abc1234 other/project@abcdef1", - url, - ), - ).toBe( - "Closes [#123](https://github.com/team/repo/issues/123), fixes [other/project#9](https://github.com/other/project/issues/9); [other#4](https://github.com/other/repo/issues/4) [@julius](https://github.com/julius) [@team/mobile](https://github.com/orgs/team/teams/mobile) [abc1234](https://github.com/team/repo/commit/abc1234) [other/project@abcdef1](https://github.com/other/project/commit/abcdef1)", - ); - }); - it("leaves code, links, images, URLs, escapes, emails, and plain numbers alone", () => { - const text = - "`#123 @person abc1234`\n\n```\n#123\n```\n\n[#123](https://example.com) ![#123](image.png) https://example.com/#123 user@example.com \\#123 1234567\n\n[ref]: https://example.com/#123"; - expect(githubMarkdownLinks(text, url)).toBe(text); - }); - it("does not nest links inside HTML anchors or code", () => { - const text = 'See #12 and #13, then #14.'; - expect(githubMarkdownLinks(text, url)).toBe( - 'See #12 and #13, then [#14](https://github.com/team/repo/issues/14).', - ); - }); -}); diff --git a/packages/shared/src/githubMarkdownLinks.ts b/packages/shared/src/githubMarkdownLinks.ts deleted file mode 100644 index 21777947b678..000000000000 --- a/packages/shared/src/githubMarkdownLinks.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { unified } from "unified"; -import remarkParse from "remark-parse"; -import remarkGfm from "remark-gfm"; - -type MarkdownNode = { - readonly type: string; - readonly position?: - | { - readonly start: { readonly offset?: number | undefined }; - readonly end: { readonly offset?: number | undefined }; - } - | undefined; - readonly children?: readonly MarkdownNode[] | undefined; -}; - -const parser = unified().use(remarkParse).use(remarkGfm); -const reference = - /(? - qualifier?.includes("/") ? qualifier : qualifier ? `${qualifier}/${repo}` : `${owner}/${repo}`; - const visit = (node: MarkdownNode) => { - if ( - [ - "link", - "linkReference", - "image", - "imageReference", - "code", - "inlineCode", - "html", - "definition", - ].includes(node.type) - ) - return; - if (node.type === "text") { - const start = node.position?.start.offset; - const end = node.position?.end.offset; - if (start === undefined || end === undefined) return; - const source = markdown.slice(start, end); - for (const match of source.matchAll(reference)) { - const [label, issueRepo, number, commitRepo, sha, mention] = match; - if (sha && !/[a-f]/i.test(sha)) continue; - const path = number - ? `${repository(issueRepo)}/issues/${number}` - : sha - ? `${repository(commitRepo)}/commit/${sha}` - : mention?.includes("/") - ? `orgs/${mention.split("/")[0]}/teams/${mention.split("/")[1]}` - : mention; - if (!path) continue; - edits.push({ - start: start + match.index, - end: start + match.index + label.length, - value: `[${label}](${url.origin}/${path})`, - }); - } - } else if ("children" in node) { - let protectedHtmlDepth = 0; - for (const child of node.children ?? []) { - if (child.type === "html") { - const start = child.position?.start.offset; - const end = child.position?.end.offset; - const tag = start === undefined || end === undefined ? "" : markdown.slice(start, end); - for (const match of tag.matchAll(/<(\/)?(?:a|code|pre|script|style)(?=[\s>])[^>]*>/gi)) { - protectedHtmlDepth = Math.max(0, protectedHtmlDepth + (match[1] ? -1 : 1)); - } - } else if (protectedHtmlDepth === 0) visit(child); - } - } - }; - visit(parser.parse(markdown)); - for (let index = edits.length - 1; index >= 0; index--) { - const edit = edits[index]!; - markdown = markdown.slice(0, edit.start) + edit.value + markdown.slice(edit.end); - } - return markdown; -} diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index 1f63cf9fe95a..bc203ef1dcf4 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -102,7 +102,6 @@ export const definition = defineSourceControlClient({ changeRequestCloseCommand: /\baz\s+repos\s+pr\s+update\b[^;&|]*--status[\s=]+["']?(?:completed|abandoned)\b/u, authorProfileUrl: () => null, - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => { const args = CHECKOUT_COMMAND.exec(input)?.[1]; diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 3ad2e97aefd5..3ed6ddeaad85 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -93,7 +93,6 @@ export const definition = defineSourceControlClient({ ? `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}` : null, authorProfileUrl: () => null, - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, isChangeRequestReference: () => false, diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 2796f24df062..53101b59955e 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -84,6 +84,16 @@ export interface SourceControlMediaUrl { readonly kind?: "image" | "video"; } +export interface ReferenceAutolink { + /** Where `text` starts in the string that was searched. */ + readonly index: number; + readonly text: string; + /** What to show for the link, which can be shorter than `text`, as for a commit SHA. */ + readonly label: string; + readonly url: string; + readonly kind: "reference" | "commit" | "mention"; +} + export interface SourceControlClientDefinition { readonly kind: SourceControlProviderKind; readonly label: string; @@ -117,7 +127,28 @@ export interface SourceControlClientDefinition { */ readonly changeRequestCloseCommand?: RegExp; readonly authorProfileUrl: (login: string, repositoryUrl: string) => string | null; - readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; + /** + * The references this host's own renderer links in plain text, such as `#12` or a commit SHA, + * resolved against `repositoryUrl`, in the order they appear. Absent for a host whose grammar + * T3 does not apply. + */ + readonly referenceAutolinks?: ( + text: string, + repositoryUrl: string, + ) => ReadonlyArray; + /** Where the host lists a change request's checks. Absent means the change request's own page. */ + readonly changeRequestChecksUrl?: (changeRequestUrl: string) => string; + /** + * The actions a server too old to report `capabilities.actions` could still take on this host's + * change requests. Absent means none. + */ + readonly changeRequestActionsWhenUnreported?: ReadonlySet; + /** + * `host` when the server lists change requests in many repositories on this host with one read, + * so a page's size bounds the rows of every repository together. Absent means one read per + * repository. + */ + readonly changeRequestListScope?: "host" | "repository"; readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean; readonly checkoutCommandArgument: (input: string) => string | null; readonly isChangeRequestReference: (url: string) => boolean; @@ -346,7 +377,6 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { changeRequestActions: new Set(), checkoutCommand: () => null, authorProfileUrl: () => null, - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, isChangeRequestReference: () => false, @@ -376,6 +406,11 @@ export interface SourceControlClientRegistry { readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; readonly find: (kind: string) => SourceControlClientDefinition | undefined; readonly findByPublicHost: (hostname: string) => SourceControlClientDefinition | undefined; + /** + * The host whose `publicHost` is `hostname` or a parent domain of it, such as GitHub for + * `gist.github.com`: which host a link is on, not one T3 can read from. + */ + readonly findByPublicDomain: (hostname: string) => SourceControlClientDefinition | undefined; /** The first host, in registry order, that reads `url` as one of its change requests. */ readonly parseChangeRequestUrl: (url: string) => ParsedChangeRequest | null; readonly findByChangeRequestUrl: (url: string) => SourceControlClientDefinition | undefined; @@ -471,6 +506,14 @@ export function makeSourceControlClientRegistry( const host = hostname.toLowerCase(); return definitions.find((definition) => definition.publicHost === host); }, + findByPublicDomain: (hostname) => { + const host = hostname.toLowerCase(); + return definitions.find( + (definition) => + definition.publicHost !== null && + (host === definition.publicHost || host.endsWith(`.${definition.publicHost}`)), + ); + }, parseChangeRequestUrl, findByChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition, hostLabelForChangeRequestUrl: (url) => diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index d6c8698f591b..ea2759a61b73 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -84,7 +84,6 @@ export const definition = defineSourceControlClient({ changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls)|fj\s+pr)\s+(?:merge|close)\b/u, authorProfileUrl: () => null, // Forgejo refuses a change request review without a summary, even with inline comments. - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: (verdict) => verdict === "request-changes", checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 50a4b33e51c5..22c96ce70c82 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -71,7 +71,6 @@ export const definition = defineSourceControlClient({ checkoutCommand: ({ number }) => `cafe pr checkout ${number}`, changeRequestCloseCommand: /\bcafe\s+(?:pr|stack)\s+(?:merge|close)\b/u, authorProfileUrl: () => null, - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, isChangeRequestReference: () => false, diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts index 7257f8fd133f..f3b08b6c647c 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -118,6 +118,25 @@ describe("toChangeRequest", () => { assert.strictEqual(row.author?.login, "remote-user"); assert.deepStrictEqual(row.reviewRequestLogins, ["alice", "upstream", "linked-local"]); }); + + it("marks a mirrored GitHub bot account as a bot", () => { + const row = (accountType: string) => + GitCafePullRequestJson.toChangeRequest( + decodePull({ + ...pull, + author: { + kind: "github", + actorId: "act_bot", + login: "coderabbitai[bot]", + accountType, + avatarUrl: null, + }, + }), + { repository: "owner/repo", host: "git.cafe" }, + ); + assert.isTrue(row("bot").author?.isBot); + assert.isUndefined(row("user").author?.isBot); + }); }); describe("toViewerPermissions", () => { diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts index f6c700dba197..a3ba916bd3bd 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -37,6 +37,7 @@ const Actor = Schema.Union([ actorId: Schema.String, login: TrimmedNonEmptyString, avatarUrl: Schema.NullOr(Schema.String), + accountType: Schema.optional(Schema.NullOr(Schema.String)), linkedProfile: Schema.optional( Schema.NullOr( Schema.Struct({ handle: TrimmedNonEmptyString, actorId: Schema.optional(Schema.String) }), @@ -300,7 +301,11 @@ function toActor(actor: Actor, host: string): PullRequestActor | null { return { login: actor.kind === "local" ? actor.handle : actor.login, name: actor.kind === "local" ? actor.displayName : null, - ...(actor.kind === "local" && actor.bot === true ? { isBot: true } : {}), + // A mirrored GitHub account says it is a bot by its account type, not its login. + ...((actor.kind === "local" && actor.bot === true) || + (actor.kind === "github" && actor.accountType === "bot") + ? { isBot: true } + : {}), // GitCafe serves its own avatars by path, so a relative one is read against the host. avatarUrl: actor.avatarUrl === null ? null : new URL(actor.avatarUrl, `https://${host}`).href, }; diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index ffc58c2dec69..1a45ab704cd5 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/source-control-core/client/definition"; import { gitHubMediaUrl } from "./gitHubMedia.ts"; +import { gitHubReferenceAutolinks } from "./referenceAutolinks.ts"; const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = @@ -117,7 +118,12 @@ export const definition = defineSourceControlClient({ login.endsWith("[bot]") ? null : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(), - referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl, + referenceAutolinks: gitHubReferenceAutolinks, + changeRequestChecksUrl: (changeRequestUrl) => `${changeRequestUrl.replace(/\/+$/u, "")}/checks`, + // Before servers reported their actions, GitHub was the only host they acted on from a list. + changeRequestActionsWhenUnreported: new Set(["close", "merge", "ready", "reopen"] as const), + // One search reads every repository on the host. + changeRequestListScope: "host", reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), diff --git a/packages/source-control-github/src/client/referenceAutolinks.test.ts b/packages/source-control-github/src/client/referenceAutolinks.test.ts new file mode 100644 index 000000000000..c3ce130b9093 --- /dev/null +++ b/packages/source-control-github/src/client/referenceAutolinks.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { gitHubReferenceAutolinks } from "./referenceAutolinks.ts"; + +const repositoryUrl = "https://github.com/team/repo"; +const linked = (text: string) => + gitHubReferenceAutolinks(text, repositoryUrl).map(({ label, url, kind }) => [label, url, kind]); + +describe("gitHubReferenceAutolinks", () => { + it("links references, cross-repository references, commits and mentions", () => { + expect( + linked( + "Closes #123, fixes other/project#9; other#4 @julius @team/mobile abc1234 other/project@abcdef1", + ), + ).toEqual([ + ["#123", "https://github.com/team/repo/issues/123", "reference"], + ["other/project#9", "https://github.com/other/project/issues/9", "reference"], + ["other#4", "https://github.com/other/repo/issues/4", "reference"], + ["@julius", "https://github.com/julius", "mention"], + ["@team/mobile", "https://github.com/orgs/team/teams/mobile", "mention"], + ["abc1234", "https://github.com/team/repo/commit/abc1234", "commit"], + ["other/project@abcdef1", "https://github.com/other/project/commit/abcdef1", "commit"], + ]); + }); + + it("shows a full SHA by its first seven characters", () => { + const sha = "0123456789abcdef0123456789abcdef01234567"; + expect(linked(`Reverts ${sha}.`)).toEqual([ + ["0123456", `https://github.com/team/repo/commit/${sha}`, "commit"], + ]); + }); + + it("leaves URLs, escapes, emails, words and plain numbers alone", () => { + expect(linked("https://example.com/#123 user@example.com \\#123 1234567 issue#x a#1b")).toEqual( + [], + ); + }); + + it("links against the repository's own host", () => { + expect( + gitHubReferenceAutolinks("See #5", "https://ghe.example.com/acme/web").map(({ url }) => url), + ).toEqual(["https://ghe.example.com/acme/web/issues/5"]); + }); +}); diff --git a/packages/source-control-github/src/client/referenceAutolinks.ts b/packages/source-control-github/src/client/referenceAutolinks.ts new file mode 100644 index 000000000000..93f7a5514e43 --- /dev/null +++ b/packages/source-control-github/src/client/referenceAutolinks.ts @@ -0,0 +1,58 @@ +import type { ReferenceAutolink } from "@t3tools/source-control-core/client/definition"; + +// GitHub's autolinked references: `#12`, `repo#12` and `owner/repo#12`; a SHA of 7 to 40 +// characters, optionally `repo@` or `owner/repo@` qualified; `@user` and `@org/team`. +const REFERENCE = + /(? { + if (!CANDIDATE.test(text)) return []; + let url: URL; + try { + url = new URL(repositoryUrl); + } catch { + return []; + } + const [owner, repo] = url.pathname.split("/").filter(Boolean); + if (!owner || !repo || !/^https?:$/u.test(url.protocol)) return []; + const repository = (qualifier: string | undefined) => + qualifier?.includes("/") ? qualifier : qualifier ? `${qualifier}/${repo}` : `${owner}/${repo}`; + const links: ReferenceAutolink[] = []; + for (const match of text.matchAll(REFERENCE)) { + const [matched, issueRepo, number, commitRepo, sha, mention] = match; + if (number) { + links.push({ + index: match.index, + text: matched, + label: matched, + url: `${url.origin}/${repository(issueRepo)}/issues/${number}`, + kind: "reference", + }); + } else if (sha) { + // A run of digits is a number far more often than a SHA. + if (!/[a-f]/iu.test(sha)) continue; + links.push({ + index: match.index, + text: matched, + label: `${commitRepo ? `${commitRepo}@` : ""}${sha.slice(0, SHORT_SHA_LENGTH)}`, + url: `${url.origin}/${repository(commitRepo)}/commit/${sha}`, + kind: "commit", + }); + } else if (mention) { + const [organization, team] = mention.split("/"); + links.push({ + index: match.index, + text: matched, + label: matched, + url: `${url.origin}/${team ? `orgs/${organization}/teams/${team}` : mention}`, + kind: "mention", + }); + } + } + return links; +} diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index 801de8ca5618..ce1262f42fb8 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -48,7 +48,6 @@ export const definition = defineSourceControlClient({ checkoutCommand: ({ number }) => `glab mr checkout ${number}`, changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|close)\b/u, authorProfileUrl: () => null, - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), From 326e08b08a96c81a286fdff13ddb38ff46dc1902 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:35 -0700 Subject: [PATCH 08/65] refactor(clients): forge settings, routing and icons come from definitions (#23998) Co-authored-by: Claude Opus 5.5 --- .../mobile/assets/link-icons/azure-devops.png | Bin 0 -> 4148 bytes apps/mobile/assets/link-icons/bitbucket.png | Bin 0 -> 1930 bytes apps/mobile/assets/link-icons/forgejo.png | Bin 0 -> 1419 bytes apps/mobile/assets/link-icons/gitcafe.png | Bin 0 -> 285 bytes apps/mobile/assets/link-icons/github.png | Bin 1727 -> 1346 bytes apps/mobile/assets/link-icons/gitlab.png | Bin 0 -> 1723 bytes apps/mobile/package.json | 1 + apps/mobile/scripts/sync-link-icons.ts | 79 +++++ apps/mobile/src/connection/storage.ts | 8 +- .../connection/ConnectionsRouteScreen.tsx | 4 +- ...ngs.tsx => PullRequestRoutingSettings.tsx} | 34 +- .../features/projects/AddProjectScreen.tsx | 6 +- .../features/projects/SourceControlIcon.tsx | 305 ++++-------------- .../SettingsEnvironmentsRouteScreen.tsx | 4 +- .../src/lib/markdownLinkIcons.generated.ts | 10 + apps/mobile/src/lib/markdownLinkIcons.ts | 27 +- .../src/native/useEnrichedLinkVariants.ts | 8 +- apps/web/src/components/Icons.tsx | 250 -------------- .../components/SourceControlIconArtwork.tsx | 59 ++++ .../settings/ConnectionsSettings.tsx | 4 +- .../settings/IntegrationsSettings.tsx | 2 +- ...ngs.tsx => PullRequestRoutingSettings.tsx} | 43 +-- ...ourceControlAccountSettings.logic.test.ts} | 20 +- ... => SourceControlAccountSettings.logic.ts} | 20 +- ...s.tsx => SourceControlAccountSettings.tsx} | 56 ++-- .../settings/SourceControlSettings.tsx | 62 ++-- ...ngs.tsx => SourceControlTokenSettings.tsx} | 77 +++-- .../settings/settingsSearch.test.ts | 53 ++- .../src/components/settings/settingsSearch.ts | 87 +++-- apps/web/src/connection/storage.test.ts | 6 +- apps/web/src/connection/storage.ts | 64 ++-- ...ation.ts => sourceControlPresentation.tsx} | 30 +- docs/internals/remote.md | 2 +- docs/user/source-control.md | 11 +- .../client-runtime/src/connection/index.ts | 2 +- ...ns.ts => pullRequestRoutingPermissions.ts} | 48 +-- .../src/connection/registry.test.ts | 75 +++-- .../client-runtime/src/connection/registry.ts | 31 +- .../src/connection/resolver.test.ts | 14 +- .../client-runtime/src/connection/resolver.ts | 12 +- .../src/connection/routes.test.ts | 8 +- .../src/platform/storageDocument.test.ts | 20 +- .../src/platform/storageDocument.ts | 6 +- .../src/sourceControlClients.ts | 16 +- .../client-runtime/src/state/connections.ts | 34 +- .../src/state/pullRequestRouting.ts | 13 +- .../src/state/pullRequests.test.ts | 22 +- packages/contracts/src/settings.ts | 4 - .../src/client/definition.ts | 3 +- .../src/client/icon.ts | 162 ++++++++++ .../src/client/definition.ts | 3 +- .../src/client/icon.ts | 29 ++ .../src/client/definition.ts | 105 +++++- .../src/client/definition.ts | 4 +- .../source-control-forgejo/src/client/icon.ts | 13 + .../src/client/definition.ts | 3 +- .../source-control-gitcafe/src/client/icon.ts | 6 + .../src/client/definition.ts | 42 +-- .../source-control-github/src/client/icon.ts | 12 + .../src/client/definition.ts | 3 +- .../source-control-gitlab/src/client/icon.ts | 23 ++ 61 files changed, 1115 insertions(+), 930 deletions(-) create mode 100644 apps/mobile/assets/link-icons/azure-devops.png create mode 100644 apps/mobile/assets/link-icons/bitbucket.png create mode 100644 apps/mobile/assets/link-icons/forgejo.png create mode 100644 apps/mobile/assets/link-icons/gitcafe.png create mode 100644 apps/mobile/assets/link-icons/gitlab.png create mode 100644 apps/mobile/scripts/sync-link-icons.ts rename apps/mobile/src/features/connection/{GitHubRoutingSettings.tsx => PullRequestRoutingSettings.tsx} (76%) create mode 100644 apps/mobile/src/lib/markdownLinkIcons.generated.ts create mode 100644 apps/web/src/components/SourceControlIconArtwork.tsx rename apps/web/src/components/settings/{GitHubRoutingSettings.tsx => PullRequestRoutingSettings.tsx} (71%) rename apps/web/src/components/settings/{GitHubAccountSettings.logic.test.ts => SourceControlAccountSettings.logic.test.ts} (73%) rename apps/web/src/components/settings/{GitHubAccountSettings.logic.ts => SourceControlAccountSettings.logic.ts} (73%) rename apps/web/src/components/settings/{GitHubAccountSettings.tsx => SourceControlAccountSettings.tsx} (81%) rename apps/web/src/components/settings/{GitHubTokenSettings.tsx => SourceControlTokenSettings.tsx} (63%) rename apps/web/src/{sourceControlPresentation.ts => sourceControlPresentation.tsx} (72%) rename packages/client-runtime/src/connection/{githubRoutingPermissions.ts => pullRequestRoutingPermissions.ts} (75%) create mode 100644 packages/source-control-azure-devops/src/client/icon.ts create mode 100644 packages/source-control-bitbucket/src/client/icon.ts create mode 100644 packages/source-control-forgejo/src/client/icon.ts create mode 100644 packages/source-control-gitcafe/src/client/icon.ts create mode 100644 packages/source-control-github/src/client/icon.ts create mode 100644 packages/source-control-gitlab/src/client/icon.ts diff --git a/apps/mobile/assets/link-icons/azure-devops.png b/apps/mobile/assets/link-icons/azure-devops.png new file mode 100644 index 0000000000000000000000000000000000000000..0d0f09b298140b37eb7c4f3f1faf6bb8aeb060bf GIT binary patch literal 4148 zcmV-45X^E=1D|BRCt`_n+H@>=emY>&N=towQg3dNM{&2Gfc05s4>xKjK)NR zCAL_CBGRR5M9;nVF-^84=4&EXwXT{ zx7M?MU@dU|=iU4J|6L@AS5)?CwO_C@%Imq=@_K%@d^-WWM*O>!Rgz|vL#h(Yj4E(4 z72s!=LU5zx90B~9_;)DF#D-(G$!a*H)xwFAPzJ$`QV4GpMibZY7vhgzmI;mLtdg{_ zO;N*5^BJ@UI|{NoD={_ zPC>#ST7v17duM3z&7dbVz$Qrp`&2a;>D6FoRDhpd22oBSB(mEX9GUEO;&)vU_|xb! zppU!Q@y@uY_6>$nZ99#k@5h%!!|_&swt*dfpfi_YKr2VLP&G- ze;*P?AI4aN;}DKJ0r#<|y0(r!gX3c&yRybacHSF%t_$NXbYgr|2MjK@!!WuP#+UEI zB(4Q!*Y1GUD}ic?N;sugft{&bK1=A?v#Mpz|kDG83L zu*#RV6YJ=hBRv5>&!1T&p z^fpE7MBU2ps40>wLm^ybx8a(XCl}`Do4m5g(>}gnZ4pu4YPq`|w4d5xzPAHr`#WHI z@R5WG14)SNFTo(H9R|_uFp7Bqli2&9n&P!4&=cxmlTru!R27&R)w-sbRSNNqLb&GL zgj;TI=_`?7IrkEs7AEho{=N*w+;hWa*mX#N0f%<>i!5$;~x?X_Ke9$5>f~$~$boZH3K_ zR#@+B1AUiHg2ez53=iv?qQS8)svnF!)s4}ob^V~fgti_DE!2@y?B^|8K@5AYzEnxJO@J%ahziS2A51y3JyE61!9*43=xYP!tXtF7`^fbk0&=VVB zlT-)$6tW-mZHh(Yc3ClElV^CBba4OY9you$k7r8gZHiAwc;@mrz#f5;5c5P+?73!e zAXf%*yPR1LUUuny{`KPdeLwefne27vgj9h`O3G~K)YR3k>B-?PDJfsMrX?SAO-nxK zmYSI4mYx(PNlx}BA|n3u)X6)oFYXX*y$jyo?tzrRCMEpiKAgU20pm-Z1pB8+_^BP# z9%29E5+)3Bk6>`2wcFrgtJWyCwZtU0CDZ&$bDVj6(@A=K-5%>i&6jpbwVN5K%GGu` zm1e!4*(D)+@}#uHf;ThbJEvwO;LVH#Ovy;Zl#C>b$!STLl$H!PlGGGTOih6#IU`RP zn>G5e$us7iv}CU>>JWW)7s4%f!QXnXp9E&Fgr25IO0XF~g4y2ocC&r$YSV)q1;&Ru zQw@LVykKy+>(Kb4UEhp9*0puaDR_@Lg(YvC#=CDsLOSX!Y)3_7+;ERp>D4Uh#H8f5 ziOH$Zkq*}+9WIIKaHhzBG$8}h#7s!zGa-r3gg8DEyx1E*_nW*V{0_vM?tqdIaTmNT z&0vSA!Pr;{d!J(1uD{vA_@ZSu<6rkbxBsdo*#4^rYiz%3op1B~gQ=D~+IZHxTWAh@ z@CNacN*I@&F1eNo$+avB@s(@}(Uoin<8%n(a&*MzfFCOZ|FR6c7#TRxGA;E-?A+tb ztmj>Awt7Dr7Sm6{Y|3F6yY9sV@lF`s$d? zXA9tTwg9ZiLO4YhP%tA4!Hg^d<4h5l6vZG+CLKLtJn2^q6{ATvCIL48e_&xr0epAmy0^Gj=Pj_{6R^Lgyq~63PYt*}wcz>Gg6~@kzF#c_0V)VL zDj^P5LrMw=SHtBq4SN0zUgP*^=W~6vKX81t7r4IlXB95O{%k|TC_mIT)Z(tL8 zRvfQtJPFsk8axu8YVdq(An>b!Ft8S)ASEQhN=U<1aM`TE#0Yiw5dI0g=I-AA4Nggf zc9bZzd8H`0*-yB!VYwivVKy(I-c=Y-=g9M}oiNbvj6|{5SE3h=mkzFX6}UcC;Q3aA z?^g{$Ks7``HIRhV!Z}O{=S@m-@^IUx(%lvxZV4mq7>L8~D8!-75QQ{B7}N-UU?X?| z4dD9K>rR&qb#U@hH!;^~${1@^nGPPx7>CuhzdEe0`QCnI)qmTssPMB}UbftJnS8d* z(h^tu#f6Tx?!^;`VTxm|D+dYNvmEU86<~W+fa6^Wu1^K{zEu$TS3$I~2IAlvNJDGj zvPlWoElNzT>cDdekc8if5Qp7?D5M#};3fz*Hi93}0G@w6xW4sZduzcW1+3Rl zODAKk3XY#D;jp?^x1(HH3A<0qVYj>tw#!OkyR;ZKOA4v0P3uLs$jj~~EBBl-`bRf1 zE$3%O(?85OVmUW;2W?LB=d@XI5#}>41)EMi?P=(CWHD>40u<}Y!1h#tvt9wNcLjK) z0KZBI1FIkos)jVA8ZMh^;rf{pZd+BDv`uw;W`uUsa|sZKHSZoYeeXK3yvQX}1IAi4 z96eOJo#g5o*srRBJ$X&BLP1?z*)A=it}<;tE`ZJA+pu1E6V?lIVdYNVfXSxrLRo&0 z4$JqGL7N>9i&=3nn;r#|H&4LGc^{lSnt138{v2cr_+& zsl}viN=O5W%4tgyp1+xkLz`xbLPsopSh>q!_0bK`=VyWb;dNNfO$BXEA}rp!3X69y z!*tp?m`phaL+L&^eOdx1k5aJK$-(iIgS$ZizK;R|{|bl#D9hx`lZ3)=xOTgVw3ZAbV0)GWWK^2gMRKj&rHJn4`uv>8H zAH;86!hi-hZa{tKbEUuRY3cU}n3vV>+&7;|KA4FjpH9Kp?Jy>Ye}t1q5!jw3;CPjQ z?<0rMPY&@$1*D-BaM@G|k>9N@ySZm(5hGPxzxuzE>5~GUKYg75D^dV;=b1eG2S|VB zC74e?57ViqU_4PLfVH+5-1Q~kd6z=qR|Zj_0@9FjxNNEb&m*(ddiFUQF_JYU0)Juq z)Me<>ucOju4mtfE0Sj-zdO_af>3{fS`q8j>CkkfM&QSu4$Rj|s8ywFf@HUh{;9Ck& zU>PLA3b=$h4nMdk&KRir(}~U_%O6T=4YtKX~%ozeFAW zqyTCOoOp;5!1F5BO+KI$lHf8p2g~8`(d8K8WtY?XI?ut=?{oMsDSnjxqWvqT_Z-&LcH6J2d3yRE=Qj%w_FkSWct*(zaVeqwx)Vj?)bCK($e_8>02+lS#R!sYuK;E{%*^5 yN%1J_kBd&&EH1cjweZ#rYxi4julh6ft^WtIlwLdQ#TTFe0000^6JV``BRCt{2nrlo`XBfv1f*=>US`etEw4Bq;MYFjjOSUZY!?LeS z=5$^`5D)|e6j7*9?0dSIn3$L>F-!JoCR=n%%(j@AY%y8($$8rZtEixWSmbIc7rFkI zw_PuAdOydOh)?o-gy(sG{XftDyr*TD*YP@*cjOVMIUYZ-1lL#|*I$VwxW@7L-CME* z=QLkK$*?^B=6GOPCc#yPBbW$#BVkIGhlODYT9+*Vf>sLP{4xe`p5b`tmN9@9M#E!k zxEl#u8nBH~>+x3D8wpct^w_|>`z=SeCe+FDn8`bX80*6j$HTbn01&i0o(4IB zpO>KmE;<0P1p06{6oxc70T{4-2{aViG+?)L1Ps_1_J%^7Y95=MH^MWBp<6NBO@%P$ zHl9NKf@?9|ic6rekR;2aQ}AAZ<#93GjfF5T2>@_B3`?M~P~|BB01d&eCD2$%Qp2Oj z-3YubRRNaA084OEs#Ld3os}JY23b`f*4VSs6m%-ImQMr#a&%b55zI;iVVt>uNJgSa zDeozb`tn~yZucs!I$1$w+G6@aAD?(bJ_ zzYSLiD}sa&y`yc~V`+X3LAvmh};&u8e$9D`#ct62~NJ$ zO&eq+6m<+m@4AENoo%2XqC}`+by4S4&j5JjOEv-oe@F!&uAqG`X15VBE)ZNtB66$$ zYDg~a`p#n^4UhLE1Tf415n>OYR{ca1n|}|h_T0rP2ZTFxmV;75YK5uLW3fyf;^=d- zR1j1rp%4ooy|S-1uHZgm^V<=-#|FZ?8eAYO2C?M^vg-%sUJJ4Wk0jg!pNR#KR%!Yv zzVHF!_TEQaK|6$MXhXCWLaS{-da=uu+i%bMEXeV=B;gpehz3y6dncjjAt;1+7YJ50 z>=vrQ+epObw-5RQ;FM$lc>E$3KuTGUdG)?dBouWZq3|K%_j*^u!jV|0hSkNLH+>e& zBPf(`1iHl?9}&r=UGs^>k7)B)y{`lA)nJEUv@J*vYDg*TKIgL}djjuE2mqW8Yl1y~ zW=;R5>vc%r2<9c+1Hy}kpRu-XSesJT z4I6|mB$YgdRSo+Vs=@n$Y_l!M9k-EHVdnfQ%<<@!OaTP-K?A5A`YpBm2~x^>ki6dx zLZ}AkNO-o(Rrz=4{C+NB2`)VYRnDes{(?0}GDgHxKbyC{1gSJ3t>P)t>=4R(kX+_zBwXyWzzHI)%5=f++8Tlnq;vxM{{lUK zw$`#ay}BRi^i9w~`WA@3&9SS&btGKW;KeRwH3M~i7thflS;8FvRm=3_+~z5xEhml2 zCYlpQg?7TIXdE}LquDTKT-RVR%8yx$@}p*BcK!3djM@QY)bu00+6KW@4GTuXqZ+7P zQV0)_b9CgLfIb11N3RqB^7OM%>86p}JcZn*7f>FbM2>a>ipFs$8pg2hm<95qqmb8| zkzM~BSw}{YRX2>x!$VkmXb5X-2a!=T0ICMZdqE1pts3kL(rK4;Bod1|=NTPV1O$My z{hm(T0wnXuJ24A&%ZwF*Y7vAS3ZZcf>l!Q$5ZM&Ok>|*&8$ssbVPv{Oa8ZMEBo?WG z+GVLz4Rj<@4?GzS=?2`SgMB1C zsKN2YiEu$GwB~?q*cu!`pVSb1s^Rz~loW#2z97A*!46@OUD`(Ce>GH?@Uae2OfbO` zTzMG)+%CwLX%Tig=+Q3IYX-)HIy?=Q$2TtvfWK-G+U00KcA0s2sDlYMI35RHHUM{O za6o9bFGwGDNg)XAl0xW5_7U^tpaEzIHirU$HSG}ebAB&Kr(M#KaBr9LV;1MH!2%7H z4(TBS;O~O8+U2-!yQB~f4MM4%{FD(BERTV3KycdSjQ@7IXhBjK^lN`n05~3hh8lps zYRFk&m%{smyxu%1>hLsJg0tZQ;Qc-^d0I38Mu!!w0XLQ$1iknBgtBoQD*e<3@c^4JxN4CRCt{2n`?-bMHt6_u6Lu0ru>=!W&&->fhK4Qeb|LTLYWl>SJ*STE zzUMvX-95YSIdA^q|7jm)=Kp_Yo_Eg7EK~rbj!~jmCYmjxNr_^cXx2$BlLdN^RLETV ziDtEE{uYe{Qt`diGMc18Gy_EOU0&fAns8JU<46)j^L|m{|6_~PF@y@4)_03$xhOV@ z!ieIaDE`Xfu{SB}=Jl*S2O!Ks_j*S|FNw(VXan@GM^oY`>1N0kJc?U#SnWlwP8E zxdg(qSzQy@k78oG*jlC$C*)x+5zQAR6W_)^+)`@SmtsS^*cxt*M>*hjWuo|?#NxBu zBQ;Er8T1s*FgGg#Vh+S6-a$0aN1WQHMYBT`ABp0fw5T?&7u}(vSw~zD%_PyBFLI#U zMKfKVrhne{6!RmB|I*c-?p2*ZdRn>-P2oYdq=gsx14cAUi!}9HS-_RV1<}kaVvr4P z?M809hI{jx>oz``XJsLMyGB_N5KV7U94a9Gr_N?5{moZAA(|IOvsg4!!j@3CL28*+ zpwOL4X6Z}EkEg``5-zWdD~;*Uu^14$~Rh8qe@|My5T#8v1p18RAsRDJD(zA3mh z1yd}H-%S}Deu(0+Py^~1+u1($z%>@u88~V|vNUkcz*ipJLtGHelc5IGb8TnZH!#&N zJctHXZY73YpePoF8Ze)$I@-1e6AZK#5}(6&sU!ooLN52 z2Dba+&Ko*ccS>r&k%^2j@KOh|?KfaUQ2%cE_+ve|k8&`ebrxc)*xMQ5ISkxRr=U6@ zc6RIroV*`t>|YY$*%|P8Q2z!V4~V}jRs&8X8;8(~oCl|?YXW#9HFnEAlV&5mF% zTTJV+W zB2TEf#M^?OjENmnltrdRR5m4cP1BayR`HZcuqVVwvtVDCmCVPAkBp2SB8oyC zK88+=o}#K^Ghzyx7`?u@JjCcNuDkEhQ}G>tW}5Bz({lQ~c*5_8pMDDK)!Y2N eEhE^g{*pP+Pr+XIX_h9?YYd*QelF{r5}E+syJ{o= literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/link-icons/github.png b/apps/mobile/assets/link-icons/github.png index 87eab9f5218ffce249bd6019b59b55d9106f44f5..502b7ada4e77bde078f00923558d629462da5002 100644 GIT binary patch delta 1327 zcmV+~1K& zun<@dd<66ZgTPMx8Pwm&-<|{R0xl9;*Ny2^D`S zdk$BI#QE(Wj-|+%0nF7R=C>w@`BOK!E*<*}V5ou3FTl~3_kOL%M83+g9S__J3{XDu zf!qg7${F`n5Bv8ed-Yj>Ydt6QL>(Xd0gJTMt+5OEOn*PUCxOMl9l-6tL*fs39_R)B z$W(++=Lt`ZCk2uZbA1Y%pFEw-_gKu6fE$1_fw2u&|3ACEfN8)y;4F{-pXoX0Ep-bh zlB&*^Yg{A%CmZIyuwFesSlP~6A^N2G@^kKa0?A>9g&SQY0OuL!ep8;4KBJQnH^F9O z*aT~A%zrz>v+|{j1mI=!>|t8k!NUO#u5yu}$xu4v`mi|dKu%sQJmVrm^9c)|rqAtn z8&lCKE;6M42R0jYh~`O7^ZB;i{IUQGj9O-k(cMO)nJ!X*BaB+^bXvvv@_T7VO%-7b z=g*44hX#X9E^>fX20guE@U6k%T^BjP;|4wb&VNw-39q=w0Uk8y8E_ibd|B-x$9Rr8 zRZU;)-Oy)C5y zz<;s;9f#J8Hw73kaFGDa3TVgMkan;nU;*TDJGZ6ElewQ5*s`-I?PNM(47KuXP;QNk zwM?4Rv(7?ZgBo%n$~eieF7Nbk`gBfI7_$Qe;d{{qF^yp+};{36@V^{T~MGC|UfEatYjM z;OiT-q;?t)A68k#r~E%kxpesqI$GOFMq*y3hw#O!`Urq?N)-Q|clJuMagFr&{eJ-L zo9PM<6Qwf!=UswPc1S|=qu5s`E@-eRJZ^GnuN9P ze3E3RulL2ti3R=V6j8!DnD2*a6y(&cfqC@Gq1hr0VK-XrIgR5lc1^8PEX!{IDhiJ)a%d=@4O$h-&PETNGqzZ ztXl_1idpuISOTLEAZ)X z7^^`ec89;9cK{p>e0mi|Rsz(gm?o_UlNbnTYxyL|BgrgF5jqV1y#?@)qc{q9e5PiJgR%UDH= z(-gIUe}=(t#W;SS;7-fsbYxBsFECwoRfR0RD1S!9I!0~+wD?&_S+5FNeVG*N*+ddM z^%J?_szi?;FBb5B!X4va0R=ZyC;{i-i#ABf!-**s^EiD%EoM+6^Yr-0>QwGF#-Q8g zG13BJxw_W2^MwC~R_d=dQ`gE7 zep8I49wp$&SZU)=g3@?NI_oVx^Ij z`~rh~vqiP7KiOb(L;-Cx43OxBM)_unTYcN61Z**SSf#5l-*xLf<>yjx705d|`TZC9 zmMhTi#s}?EeP6TMSIY0>jDw``9iZ7$C0dU&S;2<-w5X4fuD3!0S-h3Cu~jH8D}Uhs zghP{lRmloA)TiloX$7L!k1szJ@PESH!MOz#C@H#xur53dsT-Y#rkx=4fbw_?eJ^y> z%^Q@~{{&+#r%K?C&#wrM3*=z9J1Ao)ywDYE&ZkZni`otj%Cvt#-;6v*Zp|>NY3NR8Eq-|py^?1zqyO;;{eOA(-G0TE zOPYRf19zZXU*Tvth`QDYx8JWXbL6enOLBDkwzAr9p(}d#s)g&f6ytjL-DxDHcYv=w zc}?FJ!1#8Px|L=zAS*a5|WpB4cG2{noq5xz1`dkzYr} zRg~1VP#%>CHMks2@geaguYY{3jDMQm&!;%>A$<4KSjJA)*fI&wD;WLWr#Uf=jtwA` zX{`tMfm1+;4uR=zqCzrxyuAp7Z0GUbV4N1|x{#9a9LO@NZ|nzPs8)_p>W4}tp$zH3 zm1Q^7_Yz~|b6rVOX%K`AX7LU!>4koc%F^%m>794?DkaTN9)2w>%YP0nb>BT%b&#_U zt{-Hmtxw&JK@AOH&PC@&5OT!l>5x^1w3YUC`fZ(3d&(FoGFGp|a-etLcgSsPGgF4N zj=1{V`j7P84jMY;^PHc7zyL_QmMVKu;s%`5^`GNw!4aS`q<8&JFbon$zoK6DGx(9C zZ!7gRpzN$dMPq3Z>L`~09cjx_7Ydew^UEswe|#VV{{pUGd4ZqLelGw3002ovPDHLk FV1iy}F0B9n diff --git a/apps/mobile/assets/link-icons/gitlab.png b/apps/mobile/assets/link-icons/gitlab.png new file mode 100644 index 0000000000000000000000000000000000000000..28d26431001c5b37a587bb4c5268369d6599e9aa GIT binary patch literal 1723 zcmV;s21NOZP)^5ZAnByRCt{2TU%@tRTw@-Oo$SK(TMtDG~NH4vq5~QiP)fPXWDYp zfFJ?7U0R^qmbM^4@Pajs0>z+06f_V}119=_A^4y%nh2U0UVI=iUZOVbOm9LX%c&DY+qrhr!MjBK%#`5C1YGOzxbH^} zqg_}qUlQm@I*oi?Fg<`Q&{JuHYe{g|BZnagv>}r|S)`wu3jWv9qPgQ2mo)qiISg4K z-)Ej666PNA4laqLE{l1-+79=4;9leqxCN7HzS823v`PP zeLv?Fyt@=*Nu;B>29QMZHF602(4!*(tW$8ijRzOVK|R4`ktC$WkKT<6j1JFZ^JIa< z^=RZ?3z4{VBS!XHrp#nn^$ws~UWRd*)OL6fX27=R-`h#l=%{f$H$Rhd5 zh(_*7e{g}EGn4?4s;0IZgShN*&yhqLwPljpg$46@UmHvSA7DKy5ad#_!KgmIUFy0Z z$)$9cVe`p8J5vz%$?f^e{4~3n?|+)OEJ0` zvw<~)bpPb4%@FArdE{y+Xio=b-6fa>tOPn{5FMF0!rc_}z+%(l6 zXV3>fpy(DDNcY3BL^J^lRAQ}koHnGFTx-CK*>H1aU)QU>AI0ij@oW#J7;nUkuMMi@o$Mi@2vi`v@I3~X?%^GO(3O)@TWxhE2?AywxZkgX;7HdAQhd`zPy zKbf&Q97Ewvt`0Lnv_SR1dRG*#8v2$KF5Z-8&hTd3j+82V)@&fHC7M7xY}j7%YxwqN z-aMyLh0h{o9WJpVSc*X(w&fJrRhB%|SgCce(v|%9JX#BbRt8piODwm}eD5;KVt(uN zX&U`-b?i2%RFZvG22Zs_|7!Cs(Qjgv&szRI*gN5!fLfdVNNlPl%1B-%AMg^(^<1C* z!6|QqhN|c)J2K4~^JCV#On(yiv#Q_PejlsMW?p~;0*c5v9#t>P^#f4y9VdvS8QJ1X-mG+o>?C=UxO#U3(SOYHJq_ih*}HU%APXc zvvlgq3d_xJ0%L#3G33Q<`Q8)ske4|x_sceyJolh@C$0E;5v{@{svkmjEk@B@m_^>e zY}l?!^Fw%W11v^nz^Xm7Lp5;G;#py_Hq1so1C8n-%;MXvH6N*gU;C;o{IFK3B>Uo9 z&9{WQ6|=;#r19}goCFr#i7hGlkPo*ZI}q^}EmHliVBWK`W2*59JPj<;ntw~Ebr2pq z-`fRN?U|kFO>!nCnlMxM-6;87RNn;_H)uXojW1bR@>aEk+5jx^kv`)STH*v|>Prrn zd@d;KT~B0diRgCBMot+wKcOXBGQZv~NXh5Z5`14*-!x%-a!b4iEV13;lFx-9Z_!@f z5^4ixi4UzapJMztu;>o#Y{}=s)hb*PJ?siuZ9c{5AqYf#3m!+)8LZVaJG37z^8U5V z{ApmJ{rFN(UGbghX2H;FxGjDXM$MW}d<>((7I+8Uj8I<2CF=W-cT2>MKzQ&KhgW&C z7}!=cmzS?m^Wg*d_sgyBIcTOpX@L(CpX6={Uh>N8_-!;35q_0as7FD~xZfXv=+JHu z(M-nBix15UAHaW1HXk_%?ZKVS{R39P5op1O)x&UAJp=>F9(${0EE(-^seQ RCgT7A002ovPDHLkV1fiAGxq=h literal 0 HcmV?d00001 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2225adf5273b..01b54ef2f139 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -41,6 +41,7 @@ "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", "sync:chip-icons": "node scripts/sync-context-chip-icons.mjs", + "sync:link-icons": "node scripts/sync-link-icons.ts", "sync:pierre-icons": "node scripts/sync-pierre-file-icons.mjs", "measure-renders": "node scripts/measure-renders.mts", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", diff --git a/apps/mobile/scripts/sync-link-icons.ts b/apps/mobile/scripts/sync-link-icons.ts new file mode 100644 index 000000000000..981c2485a04e --- /dev/null +++ b/apps/mobile/scripts/sync-link-icons.ts @@ -0,0 +1,79 @@ +// The native markdown renderer draws a link's icon from a raster image, so each source control +// host's mark is rendered from its definition's artwork into a bundled PNG. +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { + sourceControlClients, + type SourceControlIcon, +} from "@t3tools/client-runtime/source-control-clients"; + +const appDirectory = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "..", +); +const outputDirectory = NodePath.join(appDirectory, "assets/link-icons"); +const generatedModulePath = NodePath.join(appDirectory, "src/lib/markdownLinkIcons.generated.ts"); +const sharp = NodeModule.createRequire( + NodePath.resolve(appDirectory, "../../scripts/package.json"), +)("sharp"); + +const SIZE = 64; + +function attribute(name: string, value: string | number | undefined): string { + return value === undefined ? "" : ` ${name}="${value}"`; +} + +// A monochrome mark is tinted by the renderer, which only reads its alpha. +function svgMarkup(icon: SourceControlIcon): string { + const paint = (value: SourceControlIcon["paths"][number]["fill"]) => + value === undefined + ? "none" + : typeof value === "string" + ? value.replace("currentColor", "#ffffff") + : `url(#${value.gradient})`; + const gradients = (icon.gradients ?? []) + .map( + (gradient) => + `${gradient.stops + .map( + (stop) => + ``, + ) + .join("")}`, + ) + .join(""); + const paths = icon.paths + .map( + (path) => + ``, + ) + .join(""); + return `${gradients}${paths}`; +} + +NodeFS.rmSync(outputDirectory, { recursive: true, force: true }); +NodeFS.mkdirSync(outputDirectory, { recursive: true }); + +const kinds: string[] = []; +for (const definition of sourceControlClients.definitions) { + if (definition.icon === null) continue; + await sharp(Buffer.from(svgMarkup(definition.icon)), { density: 72 * 4 }) + .resize(SIZE, SIZE, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toFile(NodePath.join(outputDirectory, `${definition.kind}.png`)); + kinds.push(definition.kind); +} + +NodeFS.writeFileSync( + generatedModulePath, + `import type { ImageSourcePropType } from "react-native";\n\nexport const SOURCE_CONTROL_LINK_ICON_SOURCES: Readonly> = {\n${kinds + .toSorted() + .map( + (kind) => + ` ${/^\w+$/u.test(kind) ? kind : `"${kind}"`}: require("../../assets/link-icons/${kind}.png"),`, + ) + .join("\n")}\n};\n`, +); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 0bb20a769ed8..83805e11942b 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -13,8 +13,8 @@ import { ConnectionTransientError, CredentialStore, ProfileStore, - GitHubRoutingPermissions, - makeGitHubRoutingPermissions, + PullRequestRoutingPermissions, + makePullRequestRoutingPermissions, } from "@t3tools/client-runtime/connection"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -42,7 +42,7 @@ function targetPersistenceError( export const layer = Layer.effectContext( Effect.gen(function* () { const catalog = yield* CatalogStore.make(); - const githubRoutingPermissions = yield* makeGitHubRoutingPermissions({ + const githubRoutingPermissions = yield* makePullRequestRoutingPermissions({ read: catalog.read.pipe(Effect.map((document) => document.githubRoutingPermissions ?? [])), write: (githubRoutingPermissions) => catalog.update((document) => ({ ...document, githubRoutingPermissions })), @@ -150,7 +150,7 @@ export const layer = Layer.effectContext( })), }); return Context.make(Persistence.ConnectionTargetStore, targetStore).pipe( - Context.add(GitHubRoutingPermissions, githubRoutingPermissions), + Context.add(PullRequestRoutingPermissions, githubRoutingPermissions), Context.add(Persistence.ConnectionRegistrationStore, registrationStore), Context.add(ProfileStore.ConnectionProfileStore, profileStore), Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index 4696b459ef0f..6192b53301cb 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -10,7 +10,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { LocalEnvironmentList } from "./LocalEnvironmentList"; -import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; +import { PullRequestRoutingSettings } from "./PullRequestRoutingSettings"; export function ConnectionsRouteScreen() { const { @@ -69,7 +69,7 @@ export function ConnectionsRouteScreen() { onSetEnabled={onSetEnvironmentEnabled} onUpdate={onUpdateEnvironment} /> - +
); diff --git a/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx b/apps/mobile/src/features/connection/PullRequestRoutingSettings.tsx similarity index 76% rename from apps/mobile/src/features/connection/GitHubRoutingSettings.tsx rename to apps/mobile/src/features/connection/PullRequestRoutingSettings.tsx index 199174208fd8..44383db74b2a 100644 --- a/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx +++ b/apps/mobile/src/features/connection/PullRequestRoutingSettings.tsx @@ -3,9 +3,9 @@ import * as Dialog from "../../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; import { connectionCatalogDisplayUrl, - gitHubRoutingConnectionKey, - gitHubRoutingPermissionFor, - type GitHubRoutingPermission, + pullRequestRoutingConnectionKey, + pullRequestRoutingPermissionFor, + type PullRequestRoutingPermission, } from "@t3tools/client-runtime/connection"; import { useState } from "react"; import { Pressable, View } from "react-native"; @@ -17,11 +17,11 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; const options: ReadonlyArray<{ - value: GitHubRoutingPermission; + value: PullRequestRoutingPermission; label: string; description: string; }> = [ - { value: "off", label: "Off", description: "Keep GitHub requests on this environment." }, + { value: "off", label: "Off", description: "Keep source control requests on this environment." }, { value: "read", label: "Read PRs", @@ -30,30 +30,32 @@ const options: ReadonlyArray<{ { value: "read-write", label: "Read and act", - description: "Actions may use broader GitHub permissions than the original environment.", + description: + "Actions may use broader source control permissions than the original environment.", }, ]; -export function GitHubRoutingSettings() { +export function PullRequestRoutingSettings() { const catalog = useAtomValue(environmentCatalog.catalogValueAtom); - const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); - const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); + const permissions = useAtomValue(environmentCatalog.pullRequestRoutingPermissionsValueAtom); + const update = useAtomCommand(environmentCatalog.setPullRequestRoutingPermission); const [expanded, setExpanded] = useState(null); const [saving, setSaving] = useState(false); if (catalog.entries.size === 0) return null; return ( - + {[...catalog.entries.values()].map((entry) => { const environmentId = entry.target.environmentId; - const selected = gitHubRoutingPermissionFor(entry, permissions); - const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null; + const selected = pullRequestRoutingPermissionFor(entry, permissions); + const disabled = + !catalog.isReady || saving || pullRequestRoutingConnectionKey(entry) === null; return ( setExpanded(expanded === environmentId ? null : environmentId)} @@ -89,7 +91,7 @@ export function GitHubRoutingSettings() { setSaving(false); if (result._tag === "Failure") Dialog.alert( - "Could not save GitHub routing permission", + "Could not save pull request routing permission", "Try again before leaving this screen.", ); }); @@ -102,8 +104,8 @@ export function GitHubRoutingSettings() { })} - Choose environments you trust to share PR data and use each other's GitHub access. Enable - both environments. This applies only to this client. + Choose environments you trust to share PR data and use each other's source control access. + Enable both environments. This applies only to this client. ); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 9ac01547d035..903a16140d6d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -475,7 +475,7 @@ function SourceControlRow(props: { /> ) : ( @@ -1113,7 +1113,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s } icon={ @@ -1137,7 +1137,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s title={target.definition.label} icon={ diff --git a/apps/mobile/src/features/projects/SourceControlIcon.tsx b/apps/mobile/src/features/projects/SourceControlIcon.tsx index fe31f90416ac..94b72ce3505b 100644 --- a/apps/mobile/src/features/projects/SourceControlIcon.tsx +++ b/apps/mobile/src/features/projects/SourceControlIcon.tsx @@ -1,261 +1,76 @@ -import Svg, { Circle, Defs, G, LinearGradient, Path, Rect, Stop } from "react-native-svg"; +import type { SourceControlIcon as SourceControlIconArtwork } from "@t3tools/client-runtime/source-control-clients"; +import { useId } from "react"; +import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; import { withUniwind } from "uniwind"; const ThemedSvg = withUniwind(Svg); +/** Draws a host's mark from its definition's artwork; `currentColor` follows `color`. */ export function SourceControlIcon(props: { - readonly kind: string; + readonly icon: SourceControlIconArtwork | null; readonly size?: number; readonly color?: string; readonly colorClassName?: string; }) { + // Gradient ids must not collide when one screen shows the same mark more than once. + const idPrefix = useId().replace(/[^\w-]/gu, ""); const size = props.size ?? 18; + const { icon } = props; + if (icon === null) return null; + const paint = (value: SourceControlIconArtwork["paths"][number]["fill"]) => + value === undefined + ? "none" + : typeof value === "string" + ? value + : `url(#${idPrefix}-${value.gradient})`; - switch (props.kind) { - case "forgejo": - return ( - - - - - - - - - - ); - case "github": - return ( - - - - ); - case "gitlab": - return ( - - - - - - - ); - case "azure-devops": - // Microsoft Azure DevOps mark via selfhst/icons (CC BY 4.0): https://github.com/selfhst/icons/blob/main/svg/azure-devops.svg - return ( - - + return ( + + {icon.gradients ? ( + + {icon.gradients.map((gradient) => ( - - - - + {gradient.stops.map((stop) => ( + + ))} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - case "bitbucket": - return ( - - - - - - - - - - - ); - case "gitcafe": - return ( - - - - ); - default: - return null; - } + ))} + + ) : null} + {icon.paths.map((path, index) => ( + + ))} + + ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 3710c2f1e919..4a23fdf98a3a 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -12,7 +12,7 @@ import { AndroidAnchoredMenu } from "../../components/ui/AndroidAnchoredMenu"; import { AndroidHeaderIconButton } from "../../components/ui/AndroidScreenHeader"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { LocalEnvironmentList } from "../connection/LocalEnvironmentList"; -import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; +import { PullRequestRoutingSettings } from "../connection/PullRequestRoutingSettings"; import { splitEnvironmentSections } from "../connection/environmentSections"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -179,7 +179,7 @@ export function SettingsEnvironmentsRouteScreen() { } : {})} /> - + ); diff --git a/apps/mobile/src/lib/markdownLinkIcons.generated.ts b/apps/mobile/src/lib/markdownLinkIcons.generated.ts new file mode 100644 index 000000000000..d6b13c789047 --- /dev/null +++ b/apps/mobile/src/lib/markdownLinkIcons.generated.ts @@ -0,0 +1,10 @@ +import type { ImageSourcePropType } from "react-native"; + +export const SOURCE_CONTROL_LINK_ICON_SOURCES: Readonly> = { + "azure-devops": require("../../assets/link-icons/azure-devops.png"), + bitbucket: require("../../assets/link-icons/bitbucket.png"), + forgejo: require("../../assets/link-icons/forgejo.png"), + gitcafe: require("../../assets/link-icons/gitcafe.png"), + github: require("../../assets/link-icons/github.png"), + gitlab: require("../../assets/link-icons/gitlab.png"), +}; diff --git a/apps/mobile/src/lib/markdownLinkIcons.ts b/apps/mobile/src/lib/markdownLinkIcons.ts index f88ac2f52bca..2aa25166daf1 100644 --- a/apps/mobile/src/lib/markdownLinkIcons.ts +++ b/apps/mobile/src/lib/markdownLinkIcons.ts @@ -1,13 +1,22 @@ -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + isMonochromeSourceControlIcon, + sourceControlClients, +} from "@t3tools/client-runtime/source-control-clients"; import type { ImageSourcePropType } from "react-native"; -// The native renderer draws a link's icon from an image, so a host's mark is a bundled image keyed -// by its definition's `icon`. A host without one keeps a plain link. -const SOURCE_CONTROL_LINK_ICON_SOURCES: Partial> = { - github: require("../../assets/link-icons/github.png"), -}; +import { SOURCE_CONTROL_LINK_ICON_SOURCES } from "./markdownLinkIcons.generated"; -export function markdownLinkIconSource(host: string): ImageSourcePropType | null { - const icon = sourceControlClients.findByPublicDomain(host)?.icon; - return (icon === undefined ? undefined : SOURCE_CONTROL_LINK_ICON_SOURCES[icon]) ?? null; +export interface MarkdownLinkIcon { + readonly source: ImageSourcePropType; + /** A mark drawn in the text colour takes the link's colour; a coloured mark keeps its own. */ + readonly tints: boolean; +} + +// `vp run sync:link-icons` renders each host's artwork into the bundled image this reads. +export function markdownLinkIcon(host: string): MarkdownLinkIcon | null { + const definition = sourceControlClients.findByPublicDomain(host); + const source = definition && SOURCE_CONTROL_LINK_ICON_SOURCES[definition.kind]; + return definition?.icon && source + ? { source, tints: isMonochromeSourceControlIcon(definition.icon) } + : null; } diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index dda01bd75451..575c6231d0fa 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -11,7 +11,7 @@ import { CONTEXT_CHIP_ICON_SOURCES } from "../lib/contextChipIcons.generated"; import { FONT_FAMILIES } from "../lib/fontFamilies"; import { contextChipIconSource } from "../lib/contextChipIconSource"; import { markdownFileIconSource } from "../lib/markdownFileIcons"; -import { markdownLinkIconSource } from "../lib/markdownLinkIcons"; +import { markdownLinkIcon } from "../lib/markdownLinkIcons"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; import type { @@ -83,12 +83,12 @@ function presentation( icon: markdownFileIconSource(link.icon), chip: { accent: contextChipPresentation("mention").accent, tintsIcon: false }, }; - const icon = link.kind === "external" ? markdownLinkIconSource(link.host) : null; + const icon = link.kind === "external" ? markdownLinkIcon(link.host) : null; return icon ? { color: props.textStyle.linkColor, - icon, - iconTintColor: props.textStyle.linkColor, + icon: icon.source, + ...(icon.tints ? { iconTintColor: props.textStyle.linkColor } : {}), } : undefined; } diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8faedef400f3..551312e23f1d 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -69,18 +69,6 @@ export const LinuxIcon: Icon = ({ className, ...props }) => ( ); -export const GitHubIcon: Icon = (props) => ( - - - -); - export const GitIcon: Icon = (props) => ( { ); }; -export const GitLabIcon = ({ - monochrome = false, - ...props -}: SVGProps & { readonly monochrome?: boolean }) => ( - - - - - - -); - -// Microsoft Azure DevOps mark via selfhst/icons (CC BY 4.0): https://github.com/selfhst/icons/blob/main/svg/azure-devops.svg -export const AzureDevOpsIcon: Icon = (props) => { - const id = useId().replaceAll(":", ""); - const gradientId = `${id}-azure`; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export const BitbucketIcon: Icon = (props) => { - const id = useId().replaceAll(":", ""); - const gradientId = `${id}-bitbucket-a`; - - return ( - - - - - - - - - - ); -}; - export const CursorIcon: Icon = ({ className, ...props }) => ( ( ); -export const ForgejoIcon: Icon = (props) => ( - -); - -export const GitCafeIcon: Icon = (props) => ( - -); - export const MacScreenRecordingIcon: Icon = (props) => { const gradientId = useId(); return ( diff --git a/apps/web/src/components/SourceControlIconArtwork.tsx b/apps/web/src/components/SourceControlIconArtwork.tsx new file mode 100644 index 000000000000..7f2ddc3914d3 --- /dev/null +++ b/apps/web/src/components/SourceControlIconArtwork.tsx @@ -0,0 +1,59 @@ +import type { SourceControlIcon } from "@t3tools/client-runtime/source-control-clients"; +import { useId, type SVGProps } from "react"; + +/** Draws a host's mark from its definition's artwork. */ +export function SourceControlIconArtwork({ + icon, + ...props +}: { readonly icon: SourceControlIcon } & SVGProps) { + // Gradient ids are document-wide, and one page can show the same mark many times. + const idPrefix = useId().replace(/[^\w-]/gu, ""); + const paint = (value: SourceControlIcon["paths"][number]["fill"]) => + value === undefined + ? "none" + : typeof value === "string" + ? value + : `url(#${idPrefix}-${value.gradient})`; + return ( + + {icon.gradients ? ( + + {icon.gradients.map((gradient) => ( + + {gradient.stops.map((stop) => ( + + ))} + + ))} + + ) : null} + {icon.paths.map((path, index) => ( + + ))} + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 3639c921f356..fea873dc4e52 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -37,7 +37,7 @@ import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; -import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; +import { PullRequestRoutingSettings } from "./PullRequestRoutingSettings"; import { Input } from "../ui/input"; import { Dialog, @@ -1533,7 +1533,7 @@ export function ConnectionsSettings() { /> ) : null} - + ); } diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 11e6603391ba..3262f44d75d8 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -780,7 +780,7 @@ function PullRequestLinksOpenInAppSetting() { return ( = [ +const options: ReadonlyArray<{ value: PullRequestRoutingPermission; label: string }> = [ { value: "off", label: "Off" }, { value: "read", label: "Read PRs" }, { value: "read-write", label: "Read and act" }, @@ -24,8 +24,11 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> const summaryLabels = { "read-write": "read and act", read: "read PRs" } as const; -function summarizeGitHubRouting( - entries: ReadonlyArray<{ readonly label: string; readonly permission: GitHubRoutingPermission }>, +function summarizePullRequestRouting( + entries: ReadonlyArray<{ + readonly label: string; + readonly permission: PullRequestRoutingPermission; + }>, ): string | null { const groups = (["read-write", "read"] as const).flatMap((permission) => { const labels = entries.filter((entry) => entry.permission === permission); @@ -36,36 +39,36 @@ function summarizeGitHubRouting( return groups.length === 0 ? null : groups.join(" · "); } -export function GitHubRoutingSettings({ +export function PullRequestRoutingSettings({ environments, }: { readonly environments: ReadonlyArray; }) { - const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); + const permissions = useAtomValue(environmentCatalog.pullRequestRoutingPermissionsValueAtom); const catalog = useAtomValue(environmentCatalog.catalogValueAtom); - const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); + const update = useAtomCommand(environmentCatalog.setPullRequestRoutingPermission); const [saving, setSaving] = useState(false); if (environments.length < 2) return null; - const { id, title } = searchableSetting("github-routing"); + const { id, title } = searchableSetting("pull-request-routing"); return ( ({ label: environment.label, - permission: gitHubRoutingPermissionFor(environment.entry, permissions), + permission: pullRequestRoutingPermissionFor(environment.entry, permissions), })), ) ?? "Off" } >

- Machines you trust here can read PR data through each other's GitHub access. Enable both - machines. Read and act may use broader permissions than the machine that owns them. This - applies only to this device. + Machines you trust here can read PR data through each other's source control access. Enable + both machines. Read and act may use broader permissions than the machine that owns them. + This applies only to this device.

{environments.map((environment) => ( setHost(event.target.value)} />
- +

{savedHosts.length === 0 - ? "No token saved; the server uses GH_TOKEN or the gh login." + ? `No token saved; the server uses ${credentials.tokenEnvVar} or the ${credentials.cliName} login.` : `Saved for ${savedHosts.join(", ")}.`}

diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 82995946155f..1584bbe719a4 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { EnvironmentId } from "@t3tools/contracts"; +import { + EnvironmentId, + makeProviderSettingsSchema, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { UNKNOWN_SOURCE_CONTROL_CLIENT } from "@t3tools/client-runtime/source-control-clients"; +import * as Schema from "effect/Schema"; import { filterAvailableSettingsSearchItems, @@ -8,6 +14,8 @@ import { isSettingsSearchScopeAvailable, searchSettings, SETTINGS_SEARCH_ITEMS, + sourceControlHostSearchItems, + sourceControlHostSettingsSearchId, type SettingsSearchItem, } from "./settingsSearch"; @@ -234,3 +242,46 @@ describe("auto-settlement search availability", () => { ).toBe(true); }); }); + +describe("source control host search items", () => { + const definition = { + ...UNKNOWN_SOURCE_CONTROL_CLIENT, + kind: SourceControlProviderKind.make("example"), + label: "Example", + searchKeywords: ["sample"], + settings: makeProviderSettingsSchema({ apiToken: Schema.String }), + }; + + it("lists a host by its label, its keywords, and its settings fields", () => { + const items = sourceControlHostSearchItems([UNKNOWN_SOURCE_CONTROL_CLIENT, definition]); + expect(items.map((item) => item.title)).toEqual(["Example credentials"]); + expect(searchSettings("sample apitoken", items).map((item) => item.id)).toEqual([ + sourceControlHostSettingsSearchId("example"), + ]); + }); + + it("lists a host with credentials by its accounts, token and CLI", () => { + const items = sourceControlHostSearchItems([ + { + ...definition, + credentials: { + cliName: "ex", + cliLabel: "Example CLI", + loginCommand: "ex login", + tokenEnvVar: "EX_TOKEN", + tokenAccess: "access", + newTokenUrl: (host) => `https://${host}/tokens`, + }, + }, + ]); + expect(items.map((item) => item.title)).toEqual(["Example accounts and token"]); + expect(searchSettings("ex switch accounts", items)).toHaveLength(1); + }); + + it("finds the built-in hosts by their own words", () => { + expect(searchSettings("codeberg").map((item) => item.id)).toContain("source-control"); + expect(searchSettings("ghes").map((item) => item.id)).toContain( + sourceControlHostSettingsSearchId("github"), + ); + }); +}); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index eac65e29742e..8d177d78859a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -4,7 +4,10 @@ import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/uti import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts"; import type { EnvironmentId } from "@t3tools/contracts"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + sourceControlClients, + type SourceControlClientDefinition, +} from "@t3tools/client-runtime/source-control-clients"; import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { commandLabel } from "./KeybindingsSettings.logic"; import { @@ -111,22 +114,42 @@ export function sourceControlHostSettingsSearchId(kind: string) { return `source-control-host-${kind}` as const; } -const SOURCE_CONTROL_HOST_SEARCH_ITEMS = sourceControlClients.definitions.flatMap((definition) => - definition.settings && definition.kind !== "github" - ? [ - { - id: sourceControlHostSettingsSearchId(definition.kind), - title: `${definition.label} credentials`, - to: "/settings/source-control" as const, - searchTerms: [ - `${definition.label} ${Object.keys(definition.settings.fields).join(" ")} token credentials sign in`, - ], - environmentOnly: true, - scope: "environment-defaults" as const, - }, - ] - : [], -); +/** Each host's own settings: its accounts and token, or the fields of its settings form. */ +export function sourceControlHostSearchItems( + definitions: ReadonlyArray, +): ReadonlyArray< + SettingsSearchItem & { readonly id: ReturnType } +> { + return definitions.flatMap((definition) => { + if (!definition.settings) return []; + const names = [definition.label, ...(definition.searchKeywords ?? [])].join(" "); + const { credentials } = definition; + return [ + { + id: sourceControlHostSettingsSearchId(definition.kind), + title: credentials + ? `${definition.label} accounts and token` + : `${definition.label} credentials`, + to: "/settings/source-control" as const, + searchTerms: [ + credentials + ? `${names} ${credentials.cliName} account login user host switch multiple accounts disable sign in token personal access token pat api key credential` + : `${names} ${Object.keys(definition.settings.fields).join(" ")} token credentials sign in`, + ], + environmentOnly: true, + scope: "environment-defaults" as const, + }, + ]; + }); +} + +/** For settings that apply to every host. */ +const SOURCE_CONTROL_HOST_LABELS = sourceControlClients.definitions + .map((definition) => definition.label) + .join(" "); +const SOURCE_CONTROL_HOST_TERMS = sourceControlClients.definitions + .flatMap((definition) => [definition.label, ...(definition.searchKeywords ?? [])]) + .join(" "); export const SETTINGS_SEARCH_ITEMS = [ { @@ -775,7 +798,9 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "pull-request-links-open-in-app", title: "Open pull requests in T3 Code", to: "/settings/integrations", - searchTerms: ["pr links github browser external click default pull request view panel"], + searchTerms: [ + `pr links ${SOURCE_CONTROL_HOST_LABELS} browser external click default pull request view panel`, + ], }, { id: "browser-auto-show-floating-preview", @@ -795,7 +820,9 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Remove agent credits when merging", to: "/settings/source-control", scope: "project-defaults", - searchTerms: ["pull request github squash co-authored-by attribution claude codex generated"], + searchTerms: [ + `pull request ${SOURCE_CONTROL_HOST_LABELS} squash co-authored-by attribution claude codex generated`, + ], }, { id: "pull-request-merge-method", @@ -810,7 +837,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/source-control", scope: "environment-defaults", searchTerms: [ - "version control git github gitlab forgejo gitea tea codeberg bitbucket azure devops hosting integrations credentials scan server environment", + `version control git ${SOURCE_CONTROL_HOST_TERMS} hosting integrations credentials scan server environment`, ], }, { @@ -831,17 +858,7 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "project-defaults", }, - { - id: "github-accounts", - title: "GitHub accounts and token", - to: "/settings/source-control", - searchTerms: [ - "github gh account login user host enterprise ghes switch multiple accounts disable sign in token personal access token pat api key credential", - ], - environmentOnly: true, - scope: "environment-defaults", - }, - ...SOURCE_CONTROL_HOST_SEARCH_ITEMS, + ...sourceControlHostSearchItems(sourceControlClients.definitions), { id: "source-control-writing-style", title: "Source control writing style", @@ -996,10 +1013,12 @@ export const SETTINGS_SEARCH_ITEMS = [ ], }, { - id: "github-routing", - title: "GitHub sharing", + id: "pull-request-routing", + title: "Pull request sharing", to: "/settings/connections", - searchTerms: ["pull request trusted environments shared credentials permissions read actions"], + searchTerms: [ + `pull request routing trusted environments shared credentials permissions read actions source control ${SOURCE_CONTROL_HOST_LABELS}`, + ], }, { id: "archive", diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index edec73309831..7afe2b1e8573 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -219,8 +219,8 @@ describe("browser GitHub routing permissions", () => { }; const firstBrowser = Object.assign(new EventTarget(), { localStorage }); const secondBrowser = Object.assign(new EventTarget(), { localStorage }); - const first = ConnectionStorage.makeBrowserGitHubRoutingPermissions(firstBrowser); - const second = ConnectionStorage.makeBrowserGitHubRoutingPermissions(secondBrowser); + const first = ConnectionStorage.makeBrowserPullRequestRoutingPermissions(firstBrowser); + const second = ConnectionStorage.makeBrowserPullRequestRoutingPermissions(secondBrowser); const entry = { target: new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("first"), @@ -268,7 +268,7 @@ describe("browser GitHub routing permissions", () => { expect(yield* second.get(entry)).toBe("off"); expect(yield* first.get(other)).toBe("read"); expect( - yield* ConnectionStorage.makeBrowserGitHubRoutingPermissions(firstBrowser).get(entry), + yield* ConnectionStorage.makeBrowserPullRequestRoutingPermissions(firstBrowser).get(entry), ).toBe("off"); yield* first.set(entry, "read-write"); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 4e7bcf3e58ec..bee5b0bc06c1 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -23,10 +23,10 @@ import { ConnectionBlockedError, CredentialStore, ProfileStore, - GitHubRoutingPermissions, - StoredGitHubRoutingPermission, - gitHubRoutingConnectionKey, - gitHubRoutingPermissionFor, + PullRequestRoutingPermissions, + StoredPullRequestRoutingPermission, + pullRequestRoutingConnectionKey, + pullRequestRoutingPermissionFor, } from "@t3tools/client-runtime/connection"; import { EnvironmentId, ServerConfig, ThreadId, VcsListRefsResult } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -496,36 +496,38 @@ export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStor return { read, update } satisfies CatalogStore; }); -const GITHUB_ROUTING_KEY_PREFIX = "t3code:github-routing:"; -const GITHUB_ROUTING_CHANGED = "t3code:github-routing-changed"; -const isStoredGitHubRoutingPermission = Schema.is(StoredGitHubRoutingPermission); -const encodeStoredGitHubRoutingPermission = Schema.encodeSync( - Schema.fromJsonString(StoredGitHubRoutingPermission), +// The stored names predate the rename and stay, so a tab still on an older version and an +// updated one read and revoke the same grants. +const PULL_REQUEST_ROUTING_KEY_PREFIX = "t3code:github-routing:"; +const PULL_REQUEST_ROUTING_CHANGED = "t3code:github-routing-changed"; +const isStoredPullRequestRoutingPermission = Schema.is(StoredPullRequestRoutingPermission); +const encodeStoredPullRequestRoutingPermission = Schema.encodeSync( + Schema.fromJsonString(StoredPullRequestRoutingPermission), ); /** Each grant has its own key so stale tabs and unrelated catalog saves cannot restore trust. */ -export function makeBrowserGitHubRoutingPermissions( +export function makeBrowserPullRequestRoutingPermissions( browser: Pick & EventTarget = window, ) { - const read = (key: string): StoredGitHubRoutingPermission | null => { + const read = (key: string): StoredPullRequestRoutingPermission | null => { try { const raw = browser.localStorage.getItem(key); const value: unknown = raw === null ? null : JSON.parse(raw); - return isStoredGitHubRoutingPermission(value) && - key === `${GITHUB_ROUTING_KEY_PREFIX}${value.environmentId}` + return isStoredPullRequestRoutingPermission(value) && + key === `${PULL_REQUEST_ROUTING_KEY_PREFIX}${value.environmentId}` ? value : null; } catch { return null; } }; - const readAll = (): ReadonlyArray => { + const readAll = (): ReadonlyArray => { try { - const values: StoredGitHubRoutingPermission[] = []; + const values: StoredPullRequestRoutingPermission[] = []; const storage = browser.localStorage; for (let index = 0; index < storage.length; index++) { const key = storage.key(index); - if (key?.startsWith(GITHUB_ROUTING_KEY_PREFIX)) { + if (key?.startsWith(PULL_REQUEST_ROUTING_KEY_PREFIX)) { const value = read(key); if (value !== null) values.push(value); } @@ -535,46 +537,46 @@ export function makeBrowserGitHubRoutingPermissions( return []; } }; - const write = (environmentId: EnvironmentId, value: StoredGitHubRoutingPermission | null) => + const write = (environmentId: EnvironmentId, value: StoredPullRequestRoutingPermission | null) => Effect.try({ try: () => { - const key = `${GITHUB_ROUTING_KEY_PREFIX}${environmentId}`; + const key = `${PULL_REQUEST_ROUTING_KEY_PREFIX}${environmentId}`; if (value === null) browser.localStorage.removeItem(key); - else browser.localStorage.setItem(key, encodeStoredGitHubRoutingPermission(value)); - browser.dispatchEvent(new Event(GITHUB_ROUTING_CHANGED)); + else browser.localStorage.setItem(key, encodeStoredPullRequestRoutingPermission(value)); + browser.dispatchEvent(new Event(PULL_REQUEST_ROUTING_CHANGED)); }, - catch: (cause) => catalogError("save GitHub routing permissions in", cause), + catch: (cause) => catalogError("save pull request routing permissions in", cause), }); - return GitHubRoutingPermissions.of({ + return PullRequestRoutingPermissions.of({ get: (entry) => Effect.sync(() => { - const value = read(`${GITHUB_ROUTING_KEY_PREFIX}${entry.target.environmentId}`); - return gitHubRoutingPermissionFor(entry, value === null ? [] : [value]); + const value = read(`${PULL_REQUEST_ROUTING_KEY_PREFIX}${entry.target.environmentId}`); + return pullRequestRoutingPermissionFor(entry, value === null ? [] : [value]); }), - changes: Stream.callback>((queue) => + changes: Stream.callback>((queue) => Effect.acquireRelease( Effect.sync(() => { const listener = (event: Event) => { if (event.type === "storage") { const key = (event as StorageEvent).key; - if (key !== null && !key?.startsWith(GITHUB_ROUTING_KEY_PREFIX)) return; + if (key !== null && !key?.startsWith(PULL_REQUEST_ROUTING_KEY_PREFIX)) return; } Queue.offerUnsafe(queue, readAll()); }; browser.addEventListener("storage", listener); - browser.addEventListener(GITHUB_ROUTING_CHANGED, listener); + browser.addEventListener(PULL_REQUEST_ROUTING_CHANGED, listener); Queue.offerUnsafe(queue, readAll()); return listener; }), (listener) => Effect.sync(() => { browser.removeEventListener("storage", listener); - browser.removeEventListener(GITHUB_ROUTING_CHANGED, listener); + browser.removeEventListener(PULL_REQUEST_ROUTING_CHANGED, listener); }), ).pipe(Effect.asVoid), ), set: (entry, permission) => { - const connectionKey = gitHubRoutingConnectionKey(entry); + const connectionKey = pullRequestRoutingConnectionKey(entry); if (connectionKey === null) return Effect.fail( new ConnectionBlockedError({ @@ -623,7 +625,7 @@ export const layer = Layer.effectContext( (owned) => owned.close, ); const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); - const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions(); + const pullRequestRoutingPermissions = makeBrowserPullRequestRoutingPermissions(); // Remembers each thread row's encoding, so a save only encodes rows that changed. const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder({ yieldToHost: yieldToBrowserTask, @@ -930,7 +932,7 @@ export const layer = Layer.effectContext( }); return Context.make(Persistence.ConnectionTargetStore, targetStore).pipe( - Context.add(GitHubRoutingPermissions, githubRoutingPermissions), + Context.add(PullRequestRoutingPermissions, pullRequestRoutingPermissions), Context.add(Persistence.ConnectionRegistrationStore, registrationStore), Context.add(ProfileStore.ConnectionProfileStore, profileStore), Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.tsx similarity index 72% rename from apps/web/src/sourceControlPresentation.ts rename to apps/web/src/sourceControlPresentation.tsx index ec8b4c7242dd..8fbc14708704 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.tsx @@ -5,31 +5,27 @@ import type { ChangeRequestTerminology, SourceControlClientDefinition, } from "@t3tools/client-runtime/source-control-clients"; -import { - AzureDevOpsIcon, - BitbucketIcon, - ForgejoIcon, - GitCafeIcon, - GitHubIcon, - GitLabIcon, -} from "./components/Icons"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { SourceControlIconArtwork } from "./components/SourceControlIconArtwork"; export type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; type SourceControlIcon = ElementType<{ className?: string }>; -const SOURCE_CONTROL_ICONS: Partial> = { - github: GitHubIcon, - gitlab: GitLabIcon, - forgejo: ForgejoIcon, - bitbucket: BitbucketIcon, - "azure-devops": AzureDevOpsIcon, - gitcafe: GitCafeIcon, -}; +const SOURCE_CONTROL_ICONS = new WeakMap(); +/** One component per definition, so a host's icon keeps its identity across renders. */ export function sourceControlIcon(definition: SourceControlClientDefinition): SourceControlIcon { - return SOURCE_CONTROL_ICONS[definition.icon] ?? PullRequestGlyph.pullRequest; + const artwork = definition.icon; + if (artwork === null) return PullRequestGlyph.pullRequest; + let Icon = SOURCE_CONTROL_ICONS.get(definition); + if (Icon === undefined) { + Icon = (props: { className?: string }) => ( + + ); + SOURCE_CONTROL_ICONS.set(definition, Icon); + } + return Icon; } export interface SourceControlPresentation { diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 5fcec03bbaf8..b581e1be1d55 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -54,7 +54,7 @@ dropped, which is how a changed LAN address replaces the old one; routes the user saved are never touched. The reported addresses are hints like any advertised endpoint, so a learned route still has to answer as this environment before it is used. -GitHub routing trust covers the whole route list. Adding or changing a route +Pull request routing trust covers the whole route list. Adding or changing a route revokes it; reordering does not, because the same addresses remain trusted. ## Hosted web is a client diff --git a/docs/user/source-control.md b/docs/user/source-control.md index cb323ea4c3bf..de63202c51eb 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -183,15 +183,16 @@ Press **Escape** before releasing to cancel. Failed closes stay in the list so y GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also supports approving waiting fork workflows and opening a revert pull request for a merged change. -GitHub sharing is off by default. In Settings → Connections → GitHub sharing (Environments on mobile), choose -**Read PRs** or **Read and act** for each environment you trust to share GitHub access. +Pull request sharing is off by default. In Settings → Connections → Pull request sharing (Pull +request routing under Environments on mobile), choose **Read PRs** or **Read and act** for each +environment you trust to share its source control access. Enable both the original environment and the environment answering its requests on this client. -**Read and act** can use broader GitHub permissions than the original environment's credential; +**Read and act** can use broader permissions than the original environment's credential; only enable it for environments you control and trust. Changing a saved endpoint or removing an environment clears its permission. -GitHub review details, linked PR status, and permitted review actions can then use another -connected environment signed in to the same GitHub account. Each needs a project on that host. +Review details, linked PR status, and permitted review actions can then use another connected +environment signed in to the same account on the same host. Each needs a project on that host. A connected local environment is preferred for actions and can answer slow or failed reads. Browsers and mobile clients need a paired environment to use its GitHub credentials. Credentials stay on their machines. Previously verified credentials remain usable for routing diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index 5686b3c3df70..02a30adb57fa 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -4,7 +4,7 @@ export * as Connectivity from "./connectivity.ts"; export * as CredentialStore from "./credentialStore.ts"; export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts"; export * from "./errors.ts"; -export * from "./githubRoutingPermissions.ts"; +export * from "./pullRequestRoutingPermissions.ts"; export * from "./hostFreeze.ts"; export * as Connection from "./layer.ts"; export * from "./model.ts"; diff --git a/packages/client-runtime/src/connection/githubRoutingPermissions.ts b/packages/client-runtime/src/connection/pullRequestRoutingPermissions.ts similarity index 75% rename from packages/client-runtime/src/connection/githubRoutingPermissions.ts rename to packages/client-runtime/src/connection/pullRequestRoutingPermissions.ts index 3da40fb65156..23f7eb59844f 100644 --- a/packages/client-runtime/src/connection/githubRoutingPermissions.ts +++ b/packages/client-runtime/src/connection/pullRequestRoutingPermissions.ts @@ -10,21 +10,21 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import type { ConnectionCatalogEntry } from "./catalog.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; -export const GitHubRoutingPermission = Schema.Literals(["off", "read", "read-write"]); -export type GitHubRoutingPermission = typeof GitHubRoutingPermission.Type; -export const StoredGitHubRoutingPermission = Schema.Struct({ +export const PullRequestRoutingPermission = Schema.Literals(["off", "read", "read-write"]); +export type PullRequestRoutingPermission = typeof PullRequestRoutingPermission.Type; +export const StoredPullRequestRoutingPermission = Schema.Struct({ environmentId: EnvironmentId, connectionKey: Schema.String, - permission: GitHubRoutingPermission, + permission: PullRequestRoutingPermission, }); -export type StoredGitHubRoutingPermission = typeof StoredGitHubRoutingPermission.Type; +export type StoredPullRequestRoutingPermission = typeof StoredPullRequestRoutingPermission.Type; /** * Trust belongs to the saved endpoints, never to an environment id advertised * by a server alone. With several routes the key covers all of them, sorted so * reordering keeps trust but adding or changing an address revokes it. */ -export function gitHubRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null { +export function pullRequestRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null { const routes = [ { target: entry.target, profile: entry.profile }, ...(entry.alternateRoutes ?? []), @@ -75,25 +75,25 @@ function routeConnectionKey( } } -export function gitHubRoutingPermissionFor( +export function pullRequestRoutingPermissionFor( entry: ConnectionCatalogEntry, - permissions: ReadonlyArray, -): GitHubRoutingPermission { - const key = gitHubRoutingConnectionKey(entry); + permissions: ReadonlyArray, +): PullRequestRoutingPermission { + const key = pullRequestRoutingConnectionKey(entry); return key === null ? "off" : (permissions.find((permission) => permission.connectionKey === key)?.permission ?? "off"); } -export class GitHubRoutingPermissions extends Context.Reference<{ - readonly get: (entry: ConnectionCatalogEntry) => Effect.Effect; - readonly changes: Stream.Stream>; +export class PullRequestRoutingPermissions extends Context.Reference<{ + readonly get: (entry: ConnectionCatalogEntry) => Effect.Effect; + readonly changes: Stream.Stream>; readonly set: ( entry: ConnectionCatalogEntry, - permission: GitHubRoutingPermission, + permission: PullRequestRoutingPermission, ) => Effect.Effect; readonly forget: (environmentId: EnvironmentId) => Effect.Effect; -}>("@t3tools/client-runtime/connection/GitHubRoutingPermissions", { +}>("@t3tools/client-runtime/connection/PullRequestRoutingPermissions", { defaultValue: () => ({ get: () => Effect.succeed("off"), changes: Stream.succeed([]), @@ -101,29 +101,29 @@ export class GitHubRoutingPermissions extends Context.Reference<{ Effect.fail( new ConnectionBlockedError({ reason: "unsupported", - detail: "GitHub routing preferences are unavailable on this client.", + detail: "Pull request routing preferences are unavailable on this client.", }), ), forget: () => Effect.void, }), }) {} -export const makeGitHubRoutingPermissions = Effect.fn("makeGitHubRoutingPermissions")( +export const makePullRequestRoutingPermissions = Effect.fn("makePullRequestRoutingPermissions")( function* (storage: { readonly read: Effect.Effect< - ReadonlyArray, + ReadonlyArray, ConnectionAttemptError >; readonly write: ( - permissions: ReadonlyArray, + permissions: ReadonlyArray, ) => Effect.Effect; }) { const state = yield* SubscriptionRef.make(yield* storage.read); const lock = yield* Semaphore.make(1); const update = ( transform: ( - current: ReadonlyArray, - ) => ReadonlyArray, + current: ReadonlyArray, + ) => ReadonlyArray, ) => lock.withPermits(1)( Effect.gen(function* () { @@ -134,14 +134,14 @@ export const makeGitHubRoutingPermissions = Effect.fn("makeGitHubRoutingPermissi yield* SubscriptionRef.set(state, next); }), ); - return GitHubRoutingPermissions.of({ + return PullRequestRoutingPermissions.of({ get: (entry) => SubscriptionRef.get(state).pipe( - Effect.map((permissions) => gitHubRoutingPermissionFor(entry, permissions)), + Effect.map((permissions) => pullRequestRoutingPermissionFor(entry, permissions)), ), changes: SubscriptionRef.changes(state), set: (entry, permission) => { - const connectionKey = gitHubRoutingConnectionKey(entry); + const connectionKey = pullRequestRoutingConnectionKey(entry); if (connectionKey === null) return Effect.fail( new ConnectionBlockedError({ diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index b9f58c63346f..0d0327c53af2 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -57,10 +57,10 @@ import * as Persistence from "../platform/persistence.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; import * as EnvironmentRegistry from "./registry.ts"; import { - GitHubRoutingPermissions, - type StoredGitHubRoutingPermission, - makeGitHubRoutingPermissions, -} from "./githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + type StoredPullRequestRoutingPermission, + makePullRequestRoutingPermissions, +} from "./pullRequestRoutingPermissions.ts"; import * as RpcSession from "../rpc/session.ts"; import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionWakeups from "./wakeups.ts"; @@ -1553,7 +1553,7 @@ describe("EnvironmentRegistry", () => { label: "Shadowed relay environment", }); const harness = yield* makeHarness([shadowedTarget]); - const permissions = yield* makeGitHubRoutingPermissions({ + const permissions = yield* makePullRequestRoutingPermissions({ read: Effect.succeed([]), write: () => Effect.void, }); @@ -1578,7 +1578,7 @@ describe("EnvironmentRegistry", () => { expect(hasRoutes(yield* Ref.get(harness.storedTargets), TARGET.environmentId)).toBe(false); }).pipe( Effect.provide(harness.layer), - Effect.provideService(GitHubRoutingPermissions, permissions), + Effect.provideService(PullRequestRoutingPermissions, permissions), Effect.scoped, ); }), @@ -1765,7 +1765,7 @@ describe("EnvironmentRegistry", () => { Effect.gen(function* () { const harness = yield* makeHarness([]); let failWrites = false; - const permissions = yield* makeGitHubRoutingPermissions({ + const permissions = yield* makePullRequestRoutingPermissions({ read: Effect.succeed([]), write: () => failWrites @@ -1822,7 +1822,7 @@ describe("EnvironmentRegistry", () => { expect(yield* permissions.get(entry)).toBe("off"); }).pipe( Effect.provide(harness.layer), - Effect.provideService(GitHubRoutingPermissions, permissions), + Effect.provideService(PullRequestRoutingPermissions, permissions), Effect.scoped, ); }), @@ -2021,33 +2021,38 @@ describe("EnvironmentRegistry routes", () => { }), ); - it.effect("revokes GitHub routing trust when a route is added but keeps it on reorder", () => - Effect.gen(function* () { - const harness = yield* makeHarness([RELAY_TARGET]); - let stored: ReadonlyArray = []; - const permissions = yield* makeGitHubRoutingPermissions({ - read: Effect.sync(() => stored), - write: (next) => Effect.sync(() => void (stored = next)), - }); - yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - const current = () => - SubscriptionRef.get(registry.entries).pipe( - Effect.map((entries) => entries.get(RELAY_TARGET.environmentId)!), - ); - yield* permissions.set(yield* current(), "read"); - yield* registry.register(lanRegistration()); - expect(yield* permissions.get(yield* current())).toBe("off"); - - yield* permissions.set(yield* current(), "read"); - yield* registry.reorderRoutes(LAN_TARGET.environmentId, ["relay", LAN_TARGET.connectionId]); - expect(yield* permissions.get(yield* current())).toBe("read"); - }).pipe( - Effect.provide(harness.layer), - Effect.provideService(GitHubRoutingPermissions, permissions), - Effect.scoped, - ); - }), + it.effect( + "revokes pull request routing trust when a route is added but keeps it on reorder", + () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET]); + let stored: ReadonlyArray = []; + const permissions = yield* makePullRequestRoutingPermissions({ + read: Effect.sync(() => stored), + write: (next) => Effect.sync(() => void (stored = next)), + }); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const current = () => + SubscriptionRef.get(registry.entries).pipe( + Effect.map((entries) => entries.get(RELAY_TARGET.environmentId)!), + ); + yield* permissions.set(yield* current(), "read"); + yield* registry.register(lanRegistration()); + expect(yield* permissions.get(yield* current())).toBe("off"); + + yield* permissions.set(yield* current(), "read"); + yield* registry.reorderRoutes(LAN_TARGET.environmentId, [ + "relay", + LAN_TARGET.connectionId, + ]); + expect(yield* permissions.get(yield* current())).toBe("read"); + }).pipe( + Effect.provide(harness.layer), + Effect.provideService(PullRequestRoutingPermissions, permissions), + Effect.scoped, + ); + }), ); it.effect("loads saved routes grouped by environment, in saved order", () => diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 76c49bc86eae..debd9d65e92a 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -40,9 +40,9 @@ import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionDriver from "./driver.ts"; import * as ConnectionWakeups from "./wakeups.ts"; import { - GitHubRoutingPermissions, - gitHubRoutingConnectionKey, -} from "./githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + pullRequestRoutingConnectionKey, +} from "./pullRequestRoutingPermissions.ts"; import { RELAY_ROUTE_ID, connectionRouteId, @@ -208,7 +208,7 @@ export const make = Effect.gen(function* () { const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup; const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; - const githubRoutingPermissions = yield* GitHubRoutingPermissions; + const pullRequestRoutingPermissions = yield* PullRequestRoutingPermissions; const connectivity = yield* Connectivity.Connectivity; const driver = yield* ConnectionDriver.ConnectionDriver; const wakeups = yield* ConnectionWakeups.ConnectionWakeups; @@ -508,7 +508,7 @@ export const make = Effect.gen(function* () { environmentId: EnvironmentId, operation: "register-connection" | "set-connection-routes", ) => - githubRoutingPermissions.forget(environmentId).pipe( + pullRequestRoutingPermissions.forget(environmentId).pipe( Effect.mapError( (error) => new Persistence.ConnectionPersistenceError({ @@ -522,12 +522,12 @@ export const make = Effect.gen(function* () { connectionRoutes(entry).map((route) => route.target as PersistedConnectionTarget); /** - * GitHub trust and an unsupported verdict both belong to the saved addresses: a changed set may + * Pull request routing trust and an unsupported verdict both belong to the saved addresses: a changed set may * reach a different server, so both are dropped and the next connection decides. */ const withRoutes = (previous: ConnectionCatalogEntry, routes: ReadonlyArray) => { const next = entryWithRoutes(previous, routes); - if (gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(next)) { + if (pullRequestRoutingConnectionKey(previous) === pullRequestRoutingConnectionKey(next)) { return { entry: next, addressesChanged: false }; } const { unsupportedReason: _reason, serverUpdateRequired: _update, ...rest } = next; @@ -604,19 +604,20 @@ export const make = Effect.gen(function* () { const previous = (yield* SubscriptionRef.get(entries)).get(target.environmentId); const entry: ConnectionCatalogEntry = previous?.unsupportedReason !== undefined && - gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) + pullRequestRoutingConnectionKey(previous) === + pullRequestRoutingConnectionKey(registered) ? { ...registered, ...unsupportedState(previous) } : registered; const persisted = (yield* Ref.get(persistedEnvironmentIds)).has(target.environmentId); if ( persisted || (previous !== undefined && - gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry)) + pullRequestRoutingConnectionKey(previous) !== pullRequestRoutingConnectionKey(entry)) ) { - const revoked = yield* githubRoutingPermissions.forget(target.environmentId).pipe( + const revoked = yield* pullRequestRoutingPermissions.forget(target.environmentId).pipe( Effect.tapError((error) => Effect.logWarning( - "Could not clear GitHub routing permission for a platform environment.", + "Could not clear pull request routing permission for a platform environment.", { environmentId: target.environmentId, error, @@ -725,10 +726,10 @@ export const make = Effect.gen(function* () { environmentId, Effect.gen(function* () { const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); - const revoked = yield* githubRoutingPermissions.forget(environmentId).pipe( + const revoked = yield* pullRequestRoutingPermissions.forget(environmentId).pipe( Effect.tapError((error) => Effect.logWarning( - "Could not clear GitHub routing permission after platform removal.", + "Could not clear pull request routing permission after platform removal.", { environmentId, error, @@ -811,7 +812,7 @@ export const make = Effect.gen(function* () { } const entry = yield* getEntry(environmentId); - yield* githubRoutingPermissions.forget(environmentId); + yield* pullRequestRoutingPermissions.forget(environmentId); yield* registrations.remove(environmentId); yield* Ref.update(persistedEnvironmentIds, (current) => { const next = new Set(current); @@ -867,7 +868,7 @@ export const make = Effect.gen(function* () { typeof globalThis.location === "undefined" || globalThis.location.protocol !== "https:"; /** - * Learned routes reuse the active route's credential, so they do not revoke GitHub routing + * Learned routes reuse the active route's credential, so they do not revoke pull request routing * trust the way a newly paired address does. */ const learnRoutes = Effect.fn("EnvironmentRegistry.learnRoutes")(function* (input: { diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 849cacbab3a5..1a5fbffddb50 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -29,10 +29,10 @@ import { import * as ConnectionProfileStore from "./profileStore.ts"; import * as RpcHttp from "../rpc/http.ts"; import { - GitHubRoutingPermissions, - gitHubRoutingConnectionKey, - makeGitHubRoutingPermissions, -} from "./githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + pullRequestRoutingConnectionKey, + makePullRequestRoutingPermissions, +} from "./pullRequestRoutingPermissions.ts"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const ENDPOINT = { @@ -275,11 +275,11 @@ describe("ConnectionResolver", () => { reason: "remote-unavailable", detail: "Could not persist routing consent.", }); - const permissions = yield* makeGitHubRoutingPermissions({ + const permissions = yield* makePullRequestRoutingPermissions({ read: Effect.succeed([ { environmentId: ENVIRONMENT_ID, - connectionKey: gitHubRoutingConnectionKey(entry)!, + connectionKey: pullRequestRoutingConnectionKey(entry)!, permission: "read-write", }, ]), @@ -328,7 +328,7 @@ describe("ConnectionResolver", () => { ); const prepare = broker .prepare(entry) - .pipe(Effect.provideService(GitHubRoutingPermissions, permissions)); + .pipe(Effect.provideService(PullRequestRoutingPermissions, permissions)); if (scenario === "revocation-failed") { expect(yield* Effect.flip(prepare)).toBe(failure); expect(savedProfile).toBe(profile); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index cfcf719d9bf9..647fbab1dba5 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -31,9 +31,9 @@ import { profileMissingError, } from "./errors.ts"; import { - GitHubRoutingPermissions, - gitHubRoutingConnectionKey, -} from "./githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + pullRequestRoutingConnectionKey, +} from "./pullRequestRoutingPermissions.ts"; import type { BearerConnectionTarget, ConnectionTarget, @@ -250,10 +250,10 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct target: prepared.bootstrap.target, }); if ( - gitHubRoutingConnectionKey(entry) !== - gitHubRoutingConnectionKey({ ...entry, profile: Option.some(preparedProfile) }) + pullRequestRoutingConnectionKey(entry) !== + pullRequestRoutingConnectionKey({ ...entry, profile: Option.some(preparedProfile) }) ) { - const permissions = yield* GitHubRoutingPermissions; + const permissions = yield* PullRequestRoutingPermissions; yield* permissions.forget(target.environmentId); } yield* profiles.put(preparedProfile); diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index 7e93017334cf..44bca8a7a9ae 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -7,7 +7,7 @@ import { type ConnectionCatalogEntry, type ConnectionRoute, } from "./catalog.ts"; -import { gitHubRoutingConnectionKey } from "./githubRoutingPermissions.ts"; +import { pullRequestRoutingConnectionKey } from "./pullRequestRoutingPermissions.ts"; import { BearerConnectionTarget, RelayConnectionTarget } from "./model.ts"; import { connectionRouteId, @@ -347,7 +347,7 @@ describe("learned routes", () => { expect(removedWithRelay(entryWithRoutes(relayOnly, [LAN, RELAY]))).toBe(false); }); - it("keeps GitHub routing trust when a route is learned", () => { + it("keeps pull request routing trust when a route is learned", () => { const learned = mergeLearnedRoutes({ entry: relayOnly, activeRoute: RELAY, @@ -360,7 +360,9 @@ describe("learned routes", () => { profile: learned[0]!.profile, alternateRoutes: learned.slice(1), }; - expect(gitHubRoutingConnectionKey(withLearned)).toBe(gitHubRoutingConnectionKey(relayOnly)); + expect(pullRequestRoutingConnectionKey(withLearned)).toBe( + pullRequestRoutingConnectionKey(relayOnly), + ); }); it("keeps the T3 Connect credential when learning over a learned T3 Connect route", () => { diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index 1ea0c9d7f1b3..60f561e3dae5 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -21,9 +21,9 @@ import { SshConnectionTarget, } from "../connection/model.ts"; import { - GitHubRoutingPermissions, - makeGitHubRoutingPermissions, -} from "../connection/githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + makePullRequestRoutingPermissions, +} from "../connection/pullRequestRoutingPermissions.ts"; import { ConnectionCatalogDocument, EMPTY_CONNECTION_CATALOG_DOCUMENT, @@ -87,10 +87,10 @@ describe("ConnectionCatalogDocument", () => { }).pipe(Effect.orDie); }), }; - const permissions = yield* makeGitHubRoutingPermissions(storage); + const permissions = yield* makePullRequestRoutingPermissions(storage); expect(yield* permissions.get(entry)).toBe("off"); yield* permissions.set(entry, "read"); - const restarted = yield* makeGitHubRoutingPermissions(storage); + const restarted = yield* makePullRequestRoutingPermissions(storage); expect(yield* restarted.get(entry)).toBe("read"); const changedEndpoint = { @@ -121,19 +121,19 @@ describe("ConnectionCatalogDocument", () => { expect(yield* restarted.get(entry)).toBe("read-write"); yield* restarted.forget(ENVIRONMENT_ID); expect(yield* restarted.get(entry)).toBe("off"); - const afterRemoval = yield* makeGitHubRoutingPermissions(storage); + const afterRemoval = yield* makePullRequestRoutingPermissions(storage); expect(yield* afterRemoval.get(entry)).toBe("off"); yield* permissions.set(entry, "read"); document = removeConnectionFromCatalog(document, ENVIRONMENT_ID); - const afterCatalogRemoval = yield* makeGitHubRoutingPermissions(storage); + const afterCatalogRemoval = yield* makePullRequestRoutingPermissions(storage); expect(yield* afterCatalogRemoval.get(entry)).toBe("off"); }), ); it.effect("requires explicit trust for primary, relay, and SSH connections independently", () => Effect.gen(function* () { - const permissions = yield* makeGitHubRoutingPermissions({ + const permissions = yield* makePullRequestRoutingPermissions({ read: Effect.succeed([]), write: () => Effect.void, }); @@ -179,8 +179,8 @@ describe("ConnectionCatalogDocument", () => { it.effect("does not enable GitHub routing when permission persistence fails", () => Effect.gen(function* () { const entry = { target: BEARER_TARGET, profile: Option.some(BEARER_PROFILE), enabled: true }; - expect(yield* (yield* GitHubRoutingPermissions).get(entry)).toBe("off"); - const permissions = yield* makeGitHubRoutingPermissions({ + expect(yield* (yield* PullRequestRoutingPermissions).get(entry)).toBe("off"); + const permissions = yield* makePullRequestRoutingPermissions({ read: Effect.succeed([]), write: () => Effect.fail( diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 8fe8533b3c56..1af8e0e3d67a 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -9,7 +9,7 @@ import { } from "../connection/catalog.ts"; import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; import * as TokenStore from "../authorization/tokenStore.ts"; -import { StoredGitHubRoutingPermission } from "../connection/githubRoutingPermissions.ts"; +import { StoredPullRequestRoutingPermission } from "../connection/pullRequestRoutingPermissions.ts"; export const StoredConnectionCredential = Schema.Struct({ connectionId: Schema.String, @@ -23,7 +23,9 @@ export const ConnectionCatalogDocument = Schema.Struct({ profiles: Schema.Array(ConnectionProfile), credentials: Schema.Array(StoredConnectionCredential), remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), - githubRoutingPermissions: Schema.optionalKey(Schema.Array(StoredGitHubRoutingPermission)), + // Pull request routing grants. The field name predates the rename and stays, so older and + // newer clients sharing this document keep reading and revoking the same grants. + githubRoutingPermissions: Schema.optionalKey(Schema.Array(StoredPullRequestRoutingPermission)), disabledEnvironmentIds: Schema.Array(EnvironmentId).pipe( Schema.withDecodingDefaultKey(Effect.succeed([])), ), diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index 87da337cdee8..ade5732df681 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -1,18 +1,24 @@ import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; import { + HostCredentialSettings, makeSourceControlClientRegistry, readSourceControlHostSettings, } from "@t3tools/source-control-core/client/definition"; import type { ServerSettings } from "@t3tools/contracts"; export { + isMonochromeSourceControlIcon, UNKNOWN_SOURCE_CONTROL_CLIENT, type ChangeRequestLink, type ChangeRequestTerminology, + type HostAccountChoice, + type HostCredentialSettings, type ParsedChangeRequestUrl, type ReferenceAutolink, type SourceControlClientDefinition, + type SourceControlHostCredentials, + type SourceControlIcon, } from "@t3tools/source-control-core/client/definition"; import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; @@ -51,8 +57,10 @@ export const sourceControlClients = makeSourceControlClientRegistry( }, ); -export type { GitHubSettings } from "@t3tools/source-control-github/client/definition"; - -export function readGitHubSettings(settings: Pick) { - return readSourceControlHostSettings(GitHub.settings, settings.sourceControlHosts.github); +/** The saved per-hostname accounts and tokens of a host whose definition has `credentials`. */ +export function readHostCredentialSettings( + settings: Pick, + kind: string, +): HostCredentialSettings { + return readSourceControlHostSettings(HostCredentialSettings, settings.sourceControlHosts[kind]); } diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 686bcfd83154..779bbd054ad3 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -10,10 +10,10 @@ import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { - GitHubRoutingPermissions, - type GitHubRoutingPermission, - type StoredGitHubRoutingPermission, -} from "../connection/githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + type PullRequestRoutingPermission, + type StoredPullRequestRoutingPermission, +} from "../connection/pullRequestRoutingPermissions.ts"; import { createAtomCommandScheduler, createRuntimeCommand, @@ -65,20 +65,22 @@ export function createEnvironmentCatalogAtoms( Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE), ).pipe(Atom.withLabel("environment-catalog-value")); - const githubRoutingPermissionsAtom = runtime.atom( - Stream.unwrap(GitHubRoutingPermissions.pipe(Effect.map((permissions) => permissions.changes))), - { initialValue: [] as ReadonlyArray }, + const pullRequestRoutingPermissionsAtom = runtime.atom( + Stream.unwrap( + PullRequestRoutingPermissions.pipe(Effect.map((permissions) => permissions.changes)), + ), + { initialValue: [] as ReadonlyArray }, ); - const githubRoutingPermissionsValueAtom = Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(githubRoutingPermissionsAtom)), () => []), - ).pipe(Atom.withLabel("environment-github-routing-permissions")); - const setGitHubRoutingPermission = createRuntimeCommand(runtime, { - label: "environment-catalog:github-routing-permission", + const pullRequestRoutingPermissionsValueAtom = Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(pullRequestRoutingPermissionsAtom)), () => []), + ).pipe(Atom.withLabel("environment-pull-request-routing-permissions")); + const setPullRequestRoutingPermission = createRuntimeCommand(runtime, { + label: "environment-catalog:pull-request-routing-permission", scheduler: commandScheduler, concurrency: serial, execute: Effect.fn(function* (input: { readonly environmentId: EnvironmentIdType; - readonly permission: GitHubRoutingPermission; + readonly permission: PullRequestRoutingPermission; }) { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId); @@ -86,7 +88,7 @@ export function createEnvironmentCatalogAtoms( return yield* new EnvironmentRegistry.EnvironmentNotRegisteredError({ environmentId: input.environmentId, }); - const permissions = yield* GitHubRoutingPermissions; + const permissions = yield* PullRequestRoutingPermissions; yield* permissions.set(entry, input.permission); }), }); @@ -190,8 +192,8 @@ export function createEnvironmentCatalogAtoms( return { catalogAtom, catalogValueAtom, - githubRoutingPermissionsValueAtom, - setGitHubRoutingPermission, + pullRequestRoutingPermissionsValueAtom, + setPullRequestRoutingPermission, networkStatusAtom, networkStatusValueAtom, stateAtom, diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index a6cd89ee8643..ef146fb1f30f 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -18,9 +18,9 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { - GitHubRoutingPermissions, - gitHubRoutingConnectionKey, -} from "../connection/githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + pullRequestRoutingConnectionKey, +} from "../connection/pullRequestRoutingPermissions.ts"; import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import * as ConnectionProfileStore from "../connection/profileStore.ts"; import type { RpcSession } from "../rpc/session.ts"; @@ -171,7 +171,7 @@ const routingAllowed = Effect.fn("PullRequestRouting.allowed")(function* ( destination.unsupportedReason !== undefined ) return false; - const permissions = yield* GitHubRoutingPermissions; + const permissions = yield* PullRequestRoutingPermissions; const source = yield* permissions.get(origin); const target = yield* permissions.get(destination); const allowed = write @@ -185,8 +185,9 @@ const routingAllowed = Effect.fn("PullRequestRouting.allowed")(function* ( const profile = yield* profiles.value .get(entry.target.connectionId) .pipe(Effect.orElseSucceed(() => Option.none())); - const key = gitHubRoutingConnectionKey(entry); - if (key === null || key !== gitHubRoutingConnectionKey({ ...entry, profile })) return false; + const key = pullRequestRoutingConnectionKey(entry); + if (key === null || key !== pullRequestRoutingConnectionKey({ ...entry, profile })) + return false; } return true; }); diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 74493ff95fbc..474ef49de53d 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -44,7 +44,7 @@ import { import * as PullRequestDiffLoader from "./pullRequestDiffHttp.ts"; import { executeAtomQuery } from "./runtime.ts"; import { createPullRequestRouter } from "./pullRequestRouting.ts"; -import { GitHubRoutingPermissions } from "../connection/githubRoutingPermissions.ts"; +import { PullRequestRoutingPermissions } from "../connection/pullRequestRoutingPermissions.ts"; const trustedRouting = { get: () => Effect.succeed("read-write" as const), @@ -198,7 +198,7 @@ for (const scenario of [ ).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), - Effect.provideService(GitHubRoutingPermissions, { + Effect.provideService(PullRequestRoutingPermissions, { ...trustedRouting, get: () => Effect.succeed(trusted ? "read-write" : "off"), }), @@ -283,7 +283,7 @@ it.effect.each([ }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), ); const first = yield* read().pipe(Effect.forkChild); yield* Deferred.await(started); @@ -347,7 +347,7 @@ it.effect.each(["revoked", "disabled"] as const)( ).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService(GitHubRoutingPermissions, { + Effect.provideService(PullRequestRoutingPermissions, { ...trustedRouting, get: () => Effect.sync(() => (trusted ? "read-write" : "off")), }), @@ -415,7 +415,7 @@ it.effect.each(["origin", "alternate"] as const)( }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), ); const first = yield* read().pipe(Effect.forkChild); yield* Deferred.await(started); @@ -479,7 +479,7 @@ it.effect("forgets routed viewed marks when a viewed-files batch fails", () => ); }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); @@ -678,7 +678,7 @@ it.effect.each([ ? request : request.pipe( Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), - Effect.provideService(GitHubRoutingPermissions, { + Effect.provideService(PullRequestRoutingPermissions, { ...trustedRouting, get: (entry) => Effect.succeed( @@ -797,7 +797,7 @@ it.effect.each( }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), ); yield* stored === "unavailable" @@ -860,7 +860,7 @@ it.live.each([undefined, true, false])( number: 7, }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); @@ -956,7 +956,7 @@ it.live( expect(invalidations).toContainEqual({ environment: "local", input: {} }); }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); @@ -1504,7 +1504,7 @@ it.effect("denies a routed write when only the origin has source-control permiss }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, fixture.environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, fixture.supervisor), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: (id, method, input) => createCommandPermissions(fixture.runtime, method).authorize( diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7923879541af..9f84636d12be 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -827,10 +827,6 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; -export const GitHubHost = TrimmedNonEmptyString.pipe( - Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()), -); - export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index bc203ef1dcf4..e209192a45a1 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -6,6 +6,7 @@ import { parseChangeRequestPath, remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; +import { icon } from "./icon.ts"; // Azure DevOps Server runs on any hostname; its `_git` route identifies it. const CHANGE_REQUEST_PATH = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u; @@ -66,7 +67,7 @@ export const definition = defineSourceControlClient({ kind: KIND, label: "Azure DevOps", pickerLabel: "Azure DevOps", - icon: "azure-devops", + icon, changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "project/repository", // `az repos` reads need the checkout's organization and project, so a bare hostname names no diff --git a/packages/source-control-azure-devops/src/client/icon.ts b/packages/source-control-azure-devops/src/client/icon.ts new file mode 100644 index 000000000000..58cfef61e4d8 --- /dev/null +++ b/packages/source-control-azure-devops/src/client/icon.ts @@ -0,0 +1,162 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +// Microsoft Azure DevOps mark via selfhst/icons (CC BY 4.0): https://github.com/selfhst/icons/blob/main/svg/azure-devops.svg +export const icon: SourceControlIcon = { + viewBox: "0 0 512 512", + gradients: [ + { + id: "a", + x1: 58.027, + y1: 356.668, + x2: 58.027, + y2: 100.668, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0, color: "#163697" }, + { offset: 0.276, color: "#2052cb" }, + { offset: 0.518, color: "#2764e7" }, + { offset: 0.879, color: "#367af2" }, + ], + }, + { + id: "b", + x1: 58.027, + y1: 351.334, + x2: 58.027, + y2: 292.668, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0, color: "#102784" }, + { offset: 1, color: "#2052cb", opacity: 0 }, + ], + }, + { + id: "c", + x1: 389.98, + y1: 57.935, + x2: 107.314, + y2: 193.935, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0, color: "#163697" }, + { offset: 0.301, color: "#2052cb" }, + { offset: 0.626, color: "#2764e7" }, + { offset: 0.926, color: "#367af2" }, + ], + }, + { + id: "d", + x1: 391.996, + y1: 76.552, + x2: 341.385, + y2: 78.224, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0, color: "#102784" }, + { offset: 1, color: "#2052cb", opacity: 0 }, + ], + }, + { + id: "e", + x1: 442.24, + y1: 54.769, + x2: 442.24, + y2: 419.38, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0.043, color: "#2052cb" }, + { offset: 0.489, color: "#367af2" }, + { offset: 0.943, color: "#16bbda" }, + ], + }, + { + id: "f", + x1: 435.596, + y1: 270.413, + x2: 425.732, + y2: 385.266, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0.134, color: "#16bbda", opacity: 0 }, + { offset: 0.932, color: "#6be7a0", opacity: 0.8629 }, + ], + }, + { + id: "g", + x1: 437.081, + y1: 204.362, + x2: 507.893, + y2: 119.081, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0, color: "#2c68e0", opacity: 0 }, + { offset: 0.898, color: "#66c0ff", opacity: 0.5 }, + ], + }, + { + id: "h", + x1: 99.399, + y1: 279.26, + x2: 352.75, + y2: 498.676, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0.005, color: "#367af2" }, + { offset: 0.507, color: "#0fafff" }, + { offset: 1, color: "#26cfe8" }, + ], + }, + { + id: "i", + x1: 184.342, + y1: 356.795, + x2: 338.581, + y2: 480.642, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0.443, color: "#26cfe8", opacity: 0 }, + { offset: 0.897, color: "#6be7a0", opacity: 0.8629 }, + ], + }, + ], + paths: [ + { + d: "M116.1 73.4c-5.8 5.8-13.7 13.8-13.7 22.6v216.4c0 8.9-3.3 17.3-9 23.4s-13.3 9.2-21.1 8.5l-42.7-3.1c-7.9-.5-15-3.9-20.3-9.3-5.7-5.7-9.3-13.7-9.3-22.6V202.7c0-8.9 3.6-16.9 9.4-22.6l2-1.9z", + fill: { gradient: "a" }, + }, + { + d: "M116.1 73.4c-5.8 5.8-13.7 13.8-13.7 22.6v216.4c0 8.9-3.3 17.3-9 23.4s-13.3 9.2-21.1 8.5l-42.7-3.1c-7.9-.5-15-3.9-20.3-9.3-5.7-5.7-9.3-13.7-9.3-22.6V202.7c0-8.9 3.6-16.9 9.4-22.6l2-1.9z", + fill: { gradient: "b" }, + }, + { + d: "m500.5 366-128 106.7c-5.5 4.6-12.7 7.4-20.5 7.4q-2.4 0-4.8-.3l-112.5-16.9v27.8c0 11.7-9.6 21.3-21.3 21.3-7.3 0-13.5-3.6-17.5-9.1l-36.2-51.4-5.4-7.6-36.6-51.9-7-10c-2.6-3.5-3.9-7.8-3.9-12.3 0-6 2.5-11.6 6.8-15.7 3.8-3.7 9.1-5.8 14.5-5.8.4 0 1 .1 1.5.1l105.2 7.5L384 366.5l93.7 6.7c.7.1 1.6.1 2.3.1 7.8 0 14.9-2.7 20.5-7.3", + fill: { gradient: "c" }, + }, + { + d: "m500.5 366-128 106.7c-5.5 4.6-12.7 7.4-20.5 7.4q-2.4 0-4.8-.3l-112.5-16.9v27.8c0 11.7-9.6 21.3-21.3 21.3-7.3 0-13.5-3.6-17.5-9.1l-36.2-51.4-5.4-7.6-36.6-51.9-7-10c-2.6-3.5-3.9-7.8-3.9-12.3 0-6 2.5-11.6 6.8-15.7 3.8-3.7 9.1-5.8 14.5-5.8.4 0 1 .1 1.5.1l105.2 7.5L384 366.5l93.7 6.7c.7.1 1.6.1 2.3.1 7.8 0 14.9-2.7 20.5-7.3", + fill: { gradient: "d" }, + }, + { + d: "M512 170.7v170.7c0 9.9-4.5 18.8-11.5 24.6l-128 106.7c7-5.9 11.5-14.7 11.5-24.6V175.3c0-16.7 13-30.7 29.8-31.9l63.9-4.6c.7-.1 1.6-.1 2.3-.1 7.8 0 14.9 2.8 20.5 7.4 7 5.8 11.5 14.6 11.5 24.6", + fill: { gradient: "e" }, + }, + { + d: "M512 170.7v170.7c0 9.9-4.5 18.8-11.5 24.6l-128 106.7c7-5.9 11.5-14.7 11.5-24.6V175.3c0-16.7 13-30.7 29.8-31.9l63.9-4.6c.7-.1 1.6-.1 2.3-.1 7.8 0 14.9 2.8 20.5 7.4 7 5.8 11.5 14.6 11.5 24.6", + fill: { gradient: "f" }, + fillOpacity: 0.7, + }, + { + d: "M512 170.7v170.7c0 9.9-4.5 18.8-11.5 24.6l-128 106.7c7-5.9 11.5-14.7 11.5-24.6V175.3c0-16.7 13-30.7 29.8-31.9l63.9-4.6c.7-.1 1.6-.1 2.3-.1 7.8 0 14.9 2.8 20.5 7.4 7 5.8 11.5 14.6 11.5 24.6", + fill: { gradient: "g" }, + }, + { + d: "M384 125.5c0 11.2-8.6 20.5-19.8 21.3-.5 0-1.1.1-1.6.1l-127.9 9.2-132.3 9.1-72.7 5.5c-6.9.4-13.2 3.1-18.2 7.4L116.1 73.4c4.7-4.7 10.9-8 17.8-9.1l100.8-15.1V21.3C234.7 9.6 244.3 0 256 0c5.8 0 11.1 2.3 14.9 6.1l33.4 32.6 16.9 16.5 40.3 39.3 16.1 15.8c4.1 3.9 6.4 9.4 6.4 15.2", + fill: { gradient: "h" }, + }, + { + d: "M384 125.5c0 11.2-8.6 20.5-19.8 21.3-.5 0-1.1.1-1.6.1l-127.9 9.2-132.3 9.1-72.7 5.5c-6.9.4-13.2 3.1-18.2 7.4L116.1 73.4c4.7-4.7 10.9-8 17.8-9.1l100.8-15.1V21.3C234.7 9.6 244.3 0 256 0c5.8 0 11.1 2.3 14.9 6.1l33.4 32.6 16.9 16.5 40.3 39.3 16.1 15.8c4.1 3.9 6.4 9.4 6.4 15.2", + fill: { gradient: "i" }, + fillOpacity: 0.5, + }, + ], +}; diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 3ed6ddeaad85..b4540f4f3bd5 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -13,6 +13,7 @@ import { parseChangeRequestPath, projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; +import { icon } from "./icon.ts"; const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; @@ -66,7 +67,7 @@ export const definition = defineSourceControlClient({ kind: KIND, label: "Bitbucket", pickerLabel: "Bitbucket", - icon: "bitbucket", + icon, changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "workspace/repository", publicHost: "bitbucket.org", diff --git a/packages/source-control-bitbucket/src/client/icon.ts b/packages/source-control-bitbucket/src/client/icon.ts new file mode 100644 index 000000000000..56902ec6148e --- /dev/null +++ b/packages/source-control-bitbucket/src/client/icon.ts @@ -0,0 +1,29 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +export const icon: SourceControlIcon = { + viewBox: "8.4 14.39 2481.29 2231.21", + gradients: [ + { + id: "a", + x1: 945.1094, + y1: 1524.8389, + x2: 944.4923, + y2: 1524.1893, + transform: "matrix(1996.6343 0 0 -1480.3047 -1884485.625 2258195)", + stops: [ + { offset: 0.18, color: "#0052CC" }, + { offset: 1, color: "#2684FF" }, + ], + }, + ], + paths: [ + { + d: "M88.92,14.4C45.02,13.83,8.97,48.96,8.41,92.86c-0.06,4.61,0.28,9.22,1.02,13.77l337.48,2048.72 c8.68,51.75,53.26,89.8,105.74,90.24h1619.03c39.38,0.5,73.19-27.9,79.49-66.78l337.49-2071.78c7.03-43.34-22.41-84.17-65.75-91.2 c-4.55-0.74-9.15-1.08-13.76-1.02L88.92,14.4z M1509.99,1495.09H993.24l-139.92-731h781.89L1509.99,1495.09z", + fill: "#2684FF", + }, + { + d: "M2379.27,763.06h-745.5l-125.12,730.42H992.31l-609.67,723.67c19.32,16.71,43.96,26,69.5,26.21h1618.13 c39.35,0.51,73.14-27.88,79.44-66.72L2379.27,763.06z", + fill: { gradient: "a" }, + }, + ], +}; diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 53101b59955e..45dda11fedc6 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -2,11 +2,16 @@ import { type PullRequestAction, type PullRequestReviewVerdict, type RepositoryIdentity, + secretSetting, SourceControlProviderKind, type SourceControlProviderInfo, + TrimmedNonEmptyString, + TrimmedString, } from "@t3tools/contracts"; import { normalizeGitRemoteUrl, parseGitRemoteRepository } from "@t3tools/shared/git"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; /** * The two strings are what `changeRequestHost` and the project's `repositoryIdentity` produce @@ -94,11 +99,107 @@ export interface ReferenceAutolink { readonly kind: "reference" | "commit" | "mention"; } +/** + * A host's mark as data, so web and mobile draw the same artwork without React in the host's + * package. Paths only: a circle or rectangle is written as a path. + */ +export interface SourceControlIcon { + readonly viewBox: string; + /** Always in user space, as `gradientUnits="userSpaceOnUse"`. */ + readonly gradients?: ReadonlyArray; + readonly paths: ReadonlyArray; +} + +export interface SourceControlIconGradient { + /** Unique within the icon; renderers make it unique on the page. */ + readonly id: string; + readonly x1: number; + readonly y1: number; + readonly x2: number; + readonly y2: number; + readonly transform?: string; + readonly stops: ReadonlyArray<{ + readonly offset: number; + readonly color: string; + readonly opacity?: number; + }>; +} + +/** A colour, `currentColor` for a mark drawn in the text colour, or one of the icon's gradients. */ +export type SourceControlIconPaint = string | { readonly gradient: string }; + +export interface SourceControlIconPath { + readonly d: string; + /** Absent means unfilled. */ + readonly fill?: SourceControlIconPaint; + readonly fillRule?: "evenodd" | "nonzero"; + readonly fillOpacity?: number; + readonly stroke?: string; + readonly strokeWidth?: number; +} + +/** Whether the mark is drawn in the text colour alone, so a client may tint it. */ +export function isMonochromeSourceControlIcon(icon: SourceControlIcon): boolean { + return icon.paths.every( + (path) => + (path.fill === undefined || path.fill === "currentColor") && + (path.stroke === undefined || path.stroke === "currentColor"), + ); +} + +/** + * For a host whose server signs in per hostname, with a saved token or one of a CLI's logins. + * Its `settings` include `hostCredentialSettingsFields`, and clients show controls for those + * instead of the generic settings form. + */ +export interface SourceControlHostCredentials { + /** The command name, as `gh`. */ + readonly cliName: string; + /** The CLI's product name, as `GitHub CLI`. */ + readonly cliLabel: string; + readonly loginCommand: string; + /** The variable the server reads a token from, which a saved token takes precedence over. */ + readonly tokenEnvVar: string; + /** What a token must be granted, completing "Give it …". */ + readonly tokenAccess: string; + readonly newTokenUrl: (host: string) => string; +} + +export const SourceControlHostName = TrimmedNonEmptyString.pipe( + Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()), +); + +export const HostAccountChoice = Schema.Struct({ + account: Schema.optionalKey(TrimmedNonEmptyString), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), +}); +export type HostAccountChoice = typeof HostAccountChoice.Type; + +/** Per-hostname settings of a host with `credentials`, keyed by lower-case hostname. */ +export const hostCredentialSettingsFields = { + hosts: Schema.Record(SourceControlHostName, HostAccountChoice).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + tokens: Schema.Record(SourceControlHostName, TrimmedString).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + secretSetting, + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), +}; + +export const HostCredentialSettings = Schema.Struct(hostCredentialSettingsFields); +export type HostCredentialSettings = typeof HostCredentialSettings.Type; + export interface SourceControlClientDefinition { readonly kind: SourceControlProviderKind; readonly label: string; readonly pickerLabel: string; - readonly icon: string; + /** Null for a host without a mark of its own; clients draw a generic change request glyph. */ + readonly icon: SourceControlIcon | null; + /** Further words settings search finds this host by, besides its label. */ + readonly searchKeywords?: ReadonlyArray; + readonly credentials?: SourceControlHostCredentials; readonly changeRequest: ChangeRequestTerminology; readonly repositoryPathHint: string; readonly publicHost: string | null; @@ -363,7 +464,7 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { kind: SourceControlProviderKind.make("unknown"), label: "source control", pickerLabel: "source control", - icon: "change-request", + icon: null, changeRequest: { shortLabel: "change request", singular: "change request" }, repositoryPathHint: "URL", publicHost: null, diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index ea2759a61b73..949cc9d04435 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -11,6 +11,7 @@ import { projectChangeRequestHost, remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; +import { icon } from "./icon.ts"; const CHECKOUT_COMMAND = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = @@ -54,7 +55,8 @@ export const definition = defineSourceControlClient({ kind: KIND, label: "Forgejo", pickerLabel: "Forgejo / Gitea", - icon: "forgejo", + icon, + searchKeywords: ["gitea", "tea", "codeberg"], changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "owner/repo", // Codeberg is the public Forgejo; any other server is self-hosted. diff --git a/packages/source-control-forgejo/src/client/icon.ts b/packages/source-control-forgejo/src/client/icon.ts new file mode 100644 index 000000000000..61c5ee88ba7f --- /dev/null +++ b/packages/source-control-forgejo/src/client/icon.ts @@ -0,0 +1,13 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +// The view box starts at -6 to move the mark 6 units in, as the published artwork's group does. +export const icon: SourceControlIcon = { + viewBox: "-6 -6 212 212", + paths: [ + { d: "M58 168 v-98 a50 50 0 0 1 50-50 h20", stroke: "#ff6600", strokeWidth: 25 }, + { d: "M58 168 v-30 a50 50 0 0 1 50-50 h20", stroke: "#d40000", strokeWidth: 25 }, + { d: "M124 20 a18 18 0 1 0 36 0 a18 18 0 1 0 -36 0z", stroke: "#ff6600", strokeWidth: 15 }, + { d: "M124 88 a18 18 0 1 0 36 0 a18 18 0 1 0 -36 0z", stroke: "#d40000", strokeWidth: 15 }, + { d: "M40 180 a18 18 0 1 0 36 0 a18 18 0 1 0 -36 0z", stroke: "#d40000", strokeWidth: 15 }, + ], +}; diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 22c96ce70c82..a2617c2db91d 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -14,6 +14,7 @@ import { parseChangeRequestPath, projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; +import { icon } from "./icon.ts"; /** GitCafe has no self-hosted installs: production and staging are its only hosts. */ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); @@ -41,7 +42,7 @@ export const definition = defineSourceControlClient({ kind: KIND, label: "GitCafe", pickerLabel: "GitCafe", - icon: "gitcafe", + icon, changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "owner/repo", publicHost: null, diff --git a/packages/source-control-gitcafe/src/client/icon.ts b/packages/source-control-gitcafe/src/client/icon.ts new file mode 100644 index 000000000000..97f38fbf41ee --- /dev/null +++ b/packages/source-control-gitcafe/src/client/icon.ts @@ -0,0 +1,6 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +export const icon: SourceControlIcon = { + viewBox: "0 0 64 64", + paths: [{ d: "M4.8 4.8h54.4v54.4H4.8z", fill: "#a78bfa" }], +}; diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 1a45ab704cd5..e583b900be43 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -1,16 +1,8 @@ -import { - GitHubHost, - makeProviderSettingsSchema, - secretSetting, - SourceControlProviderKind, - TrimmedNonEmptyString, - TrimmedString, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; +import { makeProviderSettingsSchema, SourceControlProviderKind } from "@t3tools/contracts"; import { changeRequestUrlBelow, defineSourceControlClient, + hostCredentialSettingsFields, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, @@ -19,6 +11,7 @@ import { } from "@t3tools/source-control-core/client/definition"; import { gitHubMediaUrl } from "./gitHubMedia.ts"; +import { icon } from "./icon.ts"; import { gitHubReferenceAutolinks } from "./referenceAutolinks.ts"; const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; @@ -49,30 +42,23 @@ function isRepositoryPath(repository: string): boolean { ); } -export const GitHubHostChoice = Schema.Struct({ - account: Schema.optionalKey(TrimmedNonEmptyString), - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), -}); -export type GitHubHostChoice = typeof GitHubHostChoice.Type; - -export const settings = makeProviderSettingsSchema({ - hosts: Schema.Record(GitHubHost, GitHubHostChoice).pipe( - Schema.withDecodingDefault(Effect.succeed({})), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - tokens: Schema.Record(GitHubHost, TrimmedString).pipe( - Schema.withDecodingDefault(Effect.succeed({})), - secretSetting, - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), -}); +export const settings = makeProviderSettingsSchema(hostCredentialSettingsFields); export type GitHubSettings = typeof settings.Type; export const definition = defineSourceControlClient({ kind: KIND, label: "GitHub", pickerLabel: "GitHub", - icon: "github", + icon, + searchKeywords: ["ghes", "enterprise"], + credentials: { + cliName: "gh", + cliLabel: "GitHub CLI", + loginCommand: "gh auth login", + tokenEnvVar: "GH_TOKEN", + tokenAccess: "read and write access to pull requests and contents", + newTokenUrl: (host) => `https://${host}/settings/personal-access-tokens/new`, + }, changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "owner/repo", publicHost: "github.com", diff --git a/packages/source-control-github/src/client/icon.ts b/packages/source-control-github/src/client/icon.ts new file mode 100644 index 000000000000..45ee6149f12e --- /dev/null +++ b/packages/source-control-github/src/client/icon.ts @@ -0,0 +1,12 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +export const icon: SourceControlIcon = { + viewBox: "0 0 16 16", + paths: [ + { + d: "M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z", + fill: "currentColor", + fillRule: "evenodd", + }, + ], +}; diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index ce1262f42fb8..c99b47559e92 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -7,6 +7,7 @@ import { parseChangeRequestPath, projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; +import { icon } from "./icon.ts"; const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = @@ -21,7 +22,7 @@ export const definition = defineSourceControlClient({ kind: KIND, label: "GitLab", pickerLabel: "GitLab", - icon: "gitlab", + icon, changeRequest: { shortLabel: "MR", singular: "merge request" }, repositoryPathHint: "group/project", publicHost: "gitlab.com", diff --git a/packages/source-control-gitlab/src/client/icon.ts b/packages/source-control-gitlab/src/client/icon.ts new file mode 100644 index 000000000000..b825fac21984 --- /dev/null +++ b/packages/source-control-gitlab/src/client/icon.ts @@ -0,0 +1,23 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +export const icon: SourceControlIcon = { + viewBox: "0 0 32 32", + paths: [ + { + d: "m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z", + fill: "#E24329", + }, + { + d: "m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z", + fill: "#FC6D26", + }, + { + d: "m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z", + fill: "#FCA326", + }, + { + d: "M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z", + fill: "#FC6D26", + }, + ], +}; From 218e5c09dff92c7ad6380036411233e533a18904 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:36 -0700 Subject: [PATCH 09/65] fix(clients): change requests use their host's own term (#23999) Co-authored-by: Claude Opus 5.5 --- .../src/components/ComposerContextSheet.tsx | 11 +- .../pull-requests/PullRequestControls.tsx | 10 +- .../pull-requests/PullRequestFiles.tsx | 15 ++- .../pull-requests/PullRequestListRow.tsx | 6 +- .../PullRequestMergeControls.tsx | 13 ++- .../pull-requests/PullRequestPresentation.tsx | 8 +- .../pull-requests/PullRequestReviewSheet.tsx | 4 +- .../PullRequestReviewersScreen.tsx | 8 +- .../pull-requests/PullRequestRouteScreen.tsx | 36 ++++-- .../pull-requests/PullRequestStatusRows.tsx | 34 +++--- .../guide/PullRequestGuideScreen.tsx | 6 +- .../pull-requests/pullRequestActions.test.ts | 20 +++- .../pull-requests/pullRequestActions.ts | 62 +++++++---- .../pullRequestAgentTasks.test.ts | 37 ++++-- .../pull-requests/pullRequestAgentTasks.ts | 31 ++++-- .../pullRequestDiffPages.test.ts | 23 +++- .../pull-requests/pullRequestDiffPages.ts | 4 +- .../features/pull-requests/pullRequestLink.ts | 6 +- .../pull-requests/pullRequestStack.test.ts | 32 ++++-- .../pull-requests/pullRequestStack.ts | 11 +- .../pull-requests/usePullRequestAction.ts | 3 +- .../pull-requests/usePullRequestDiff.ts | 10 +- .../usePullRequestListActions.ts | 105 ++++++++++-------- .../pull-requests/usePullRequestThreads.ts | 14 ++- .../threads/GitActionProgressOverlay.tsx | 8 +- .../threads/LinkedPullRequestList.tsx | 12 +- .../features/threads/ThreadGitControls.tsx | 13 ++- .../threads/ThreadPullRequestsSheet.tsx | 14 +-- .../features/threads/git/GitConfirmSheet.tsx | 9 +- .../features/threads/git/GitOverviewSheet.tsx | 17 ++- .../threads/git/gitSheetComponents.tsx | 7 +- .../new-task-context-presentation.test.ts | 17 +++ .../threads/new-task-context-presentation.ts | 4 +- .../threads/use-open-thread-pull-requests.ts | 6 +- .../threads/useLinkedPullRequestActions.ts | 36 ++++-- apps/mobile/src/lib/composerContext.test.ts | 19 ++++ apps/mobile/src/lib/composerContext.ts | 6 +- .../src/state/thread-pr-presentation.ts | 16 ++- .../src/state/use-thread-outbox-drain.ts | 10 +- apps/mobile/src/state/use-thread-pr.test.ts | 15 +++ .../BranchToolbarBranchSelector.tsx | 2 +- apps/web/src/components/CommandPalette.tsx | 2 +- .../GitActionsControl.logic.test.ts | 33 +++++- .../src/components/GitActionsControl.logic.ts | 57 +++------- apps/web/src/components/GitActionsControl.tsx | 3 +- .../components/PullRequestContextDetails.tsx | 12 +- .../components/PullRequestThreadDialog.tsx | 4 +- .../src/components/ThreadStatusIndicators.tsx | 12 +- .../components/chat/ThreadDetailsPrRow.tsx | 19 +++- .../pullRequest/PullRequestActionsMenu.tsx | 37 ++++-- .../PullRequestActivityUnavailableState.tsx | 5 +- .../pullRequest/PullRequestCodeTab.tsx | 13 ++- .../pullRequest/PullRequestCommentForm.tsx | 3 +- .../pullRequest/PullRequestComposer.tsx | 17 ++- .../pullRequest/PullRequestDetailActions.tsx | 5 +- .../pullRequest/PullRequestDetailCrumbs.tsx | 2 +- .../pullRequest/PullRequestDetailPanel.tsx | 76 +++++++------ .../PullRequestDiffUnavailableState.tsx | 8 +- .../pullRequest/PullRequestGhosts.tsx | 33 ++++-- .../pullRequest/PullRequestReviewForm.tsx | 14 ++- .../components/pullRequest/PullRequestRow.tsx | 3 +- .../pullRequest/PullRequestSpeedActions.tsx | 8 +- .../pullRequest/PullRequestStackMenu.tsx | 9 +- .../pullRequest/PullRequestSummaryTab.tsx | 19 +++- .../pullRequest/PullRequestThreadLinks.tsx | 9 +- .../pullRequest/PullRequestTimelineTab.tsx | 16 ++- .../pullRequest/PullRequestTitleEditor.tsx | 6 +- .../pullRequest/PullRequestsPage.tsx | 13 ++- .../pullRequest/ThreadPullRequestsPanel.tsx | 14 ++- .../pullRequest/pullRequestCheckoutToast.ts | 15 ++- .../pullRequestDetail.logic.test.ts | 49 +++++++- .../pullRequest/pullRequestDetail.logic.ts | 56 ++++++---- .../pullRequest/pullRequestDiffError.test.ts | 10 +- .../pullRequest/pullRequestDiffError.ts | 4 +- .../pullRequest/usePullRequestActions.ts | 65 ++++++----- .../src/lib/composerContextRecords.test.ts | 33 ++++++ apps/web/src/lib/composerContextRecords.ts | 11 +- .../src/pullRequestLinkInput.test.ts | 18 +++ .../src/pullRequestLinkInput.ts | 8 +- .../src/sourceControlClients.ts | 5 + .../src/state/gitActions.test.ts | 96 ++++++++++++++++ .../client-runtime/src/state/gitActions.ts | 56 ++++++---- .../src/work-log/presentation.test.ts | 13 +++ .../src/work-log/presentation.ts | 10 +- .../src/client/definition.ts | 7 +- .../src/client/definition.ts | 7 +- .../src/client/definition.ts | 10 +- .../src/client/definition.ts | 7 +- .../src/client/definition.ts | 7 +- .../src/client/definition.ts | 7 +- .../src/client/definition.ts | 7 +- 91 files changed, 1220 insertions(+), 453 deletions(-) create mode 100644 packages/client-runtime/src/state/gitActions.test.ts diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 1cafc5814c54..598e3cf10d17 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -11,6 +11,7 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { videoMimeType } from "@t3tools/shared/video"; import { Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -182,6 +183,10 @@ export function ComposerContextSheet(props: { record?.kind === "review-comment" && "pullRequest" in record ? record.pullRequest?.url : undefined; + const changeRequestTerm = ( + (pullRequestUrl ? sourceControlClients.findByChangeRequestUrl(pullRequestUrl) : undefined) ?? + sourceControlClients.get(undefined) + ).changeRequest.singular; const terminal = record?.kind === "terminal" && !("payload" in record) ? record : null; return ( @@ -354,12 +359,12 @@ export function ComposerContextSheet(props: { onPress={() => { props.onClose(); void openPullRequest(pullRequestUrl).catch(() => - Dialog.alert("Could not open pull request", "Try again when connected."), + Dialog.alert(`Could not open ${changeRequestTerm}`, "Try again when connected."), ); }} className="rounded-xl bg-subtle p-4" > - Open pull request + Open {changeRequestTerm} ) : null} {props.onOpenAttachment ? ( @@ -372,7 +377,7 @@ export function ComposerContextSheet(props: { ) : null} {props.onOpenPullRequest && !pullRequestUrl ? (
); + const term = sourceControlClients.get(detail.provider).changeRequest.singular; if (diffQuery.isPending && loadedSlices.length === 0) { - return withToolbar(); + return withToolbar(); } if (diffQuery.error && loadedSlices.length === 0) { @@ -1349,9 +1354,7 @@ function PullRequestCodeTab({ if (items.length === 0 && nextCursor === null) { return withToolbar(

- {commit === null - ? "This pull request has no file changes." - : "This commit has no file changes."} + {commit === null ? `This ${term} has no file changes.` : "This commit has no file changes."}

, ); } @@ -1477,7 +1480,7 @@ function PullRequestCodeTab({ {fileTreeOpen ? (
+ ) : null} {threadStatusLine} ) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1516a6cd992f..f0ae6eddb978 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -64,6 +64,7 @@ import { SettingsIcon, SquarePenIcon, TextSearchIcon, + UserPlusIcon, } from "lucide-react"; import { requestThreadFindOpen } from "./chat/threadFindActionBus"; import { @@ -217,6 +218,8 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; +import { canShareWithGuestAtom } from "./chat/guestShareAvailability"; +import { openGuestShareDialog } from "./chat/guestShareDialogState"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { settingsForThisPlatform, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; @@ -540,6 +543,9 @@ function OpenCommandPaletteDialog(props: { const activeThreadServerConfig = useServerConfigs().get( activeThread?.environmentId ?? ("" as EnvironmentId), ); + const canShareActiveThreadWithGuest = useAtomValue( + canShareWithGuestAtom(activeThread?.environmentId ?? ("" as EnvironmentId)), + ); const activeThreadReferenceCopyTarget = referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl) ? null @@ -1364,6 +1370,20 @@ function OpenCommandPaletteDialog(props: { } } + if (activeThread !== null && canShareActiveThreadWithGuest) { + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + actionItems.push({ + kind: "action", + value: "action:share-thread-with-guest", + searchTerms: ["share", "guest", "invite", "link", "teammate"], + title: "Share with guest", + icon: , + run: async () => { + openGuestShareDialog(threadRef); + }, + }); + } + if (activeThread !== null) { const thread = activeThread; actionItems.push({ diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index d9bf14748258..aa595c9186e5 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -207,7 +207,10 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "../state/server"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { canShareWithGuestAtom } from "./chat/guestShareAvailability"; +import { openGuestShareDialog } from "./chat/guestShareDialogState"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -2165,6 +2168,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] : []), { id: "rename", label: "Rename thread", disabled: !canOperateThread }, + ...(appAtomRegistry.get(canShareWithGuestAtom(thread.environmentId)) + ? [{ id: "share-guest", label: "Share with guest" }] + : []), { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, @@ -2216,6 +2222,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } + if (clicked === "share-guest") { + openGuestShareDialog(threadRef); + return; + } if (clicked === "mark-unread") { markThreadUnread(threadRef); return; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0b103763f1fc..503a6160df77 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -272,6 +272,19 @@ describe("resolveSidebarThreadStatus", () => { ).toBe("approval"); }); + it("shows a waiting guest message as a Request, below the agent's own requests", () => { + expect(resolveSidebarThreadStatus({ ...idle, pendingGuestMessageCount: 1, runtime })).toBe( + "request", + ); + expect( + resolveSidebarThreadStatus({ + ...idle, + hasPendingUserInput: true, + pendingGuestMessageCount: 1, + }), + ).toBe("input"); + }); + it("keeps usage-limit stops Limited and visible until the thread recovers", () => { const limited = { ...runtime, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 97046c71a11c..0e6d4f5a838a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -589,6 +589,7 @@ export interface ThreadStatusPill { | "Failed" | "Pending Approval" | "Awaiting Input" + | "Guest Request" | "Waiting" | "Plan Ready"; colorClass: string; @@ -599,6 +600,7 @@ export interface ThreadStatusPill { const THREAD_STATUS_PRIORITY: Record = { "Pending Approval": 5, "Awaiting Input": 4, + "Guest Request": 3.75, Failed: 3.5, Working: 3, Connecting: 3, @@ -613,6 +615,7 @@ type ThreadStatusInput = Pick< | "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" + | "pendingGuestMessageCount" | "interactionMode" | "latestRun" | "runtime" @@ -866,6 +869,7 @@ export function isContextMenuPointerDown(input: { export type SidebarThreadStatus = | "approval" | "input" + | "request" | "working" | "waiting" | "failed" @@ -879,7 +883,14 @@ export function shouldRecedeSidebarThread(input: { isActive: boolean; isSelected: boolean; }): boolean { - if (input.isActive || input.isSelected || input.status === "input") return false; + if ( + input.isActive || + input.isSelected || + input.status === "input" || + input.status === "request" + ) { + return false; + } if (input.status === "working" || input.status === "waiting") return true; if (input.status === "ready" || input.status === "approval") { return !input.isUnread && !input.isWoke; @@ -892,6 +903,7 @@ type SidebarThreadStatusInput = Pick< | "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" + | "pendingGuestMessageCount" | "runtime" | "blockedOnThreadWait" >; @@ -903,6 +915,10 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") { return "input"; } + // A guest is waiting on the owner; the agent's own requests come first. + if ((thread.pendingGuestMessageCount ?? 0) > 0) { + return "request"; + } if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) { return "waiting"; } @@ -927,6 +943,7 @@ export type SidebarV2TopStatusKind = | "failed" | "limited" | "input" + | "request" | "waiting" | "woke" | "working"; @@ -945,8 +962,8 @@ export function resolveSidebarV2TopStatus(input: { if (input.status === "approval") { return "approval"; } - if (input.status === "input") { - return "input"; + if (input.status === "input" || input.status === "request") { + return input.status; } if (input.status === "failed" || input.status === "limited") { return input.status; @@ -1092,6 +1109,15 @@ export function resolveThreadStatusPill(input: { }; } + if ((thread.pendingGuestMessageCount ?? 0) > 0) { + return { + label: "Guest Request", + colorClass: "text-guest-foreground", + dotClass: "bg-guest", + pulse: false, + }; + } + if ( (thread.runtime?.status === "running" && thread.blockedOnThreadWait !== true) || thread.runtime?.status === "waiting" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 827f62ee6119..faee72b80f37 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -126,12 +126,15 @@ import { useQuietThreadShells, } from "../state/entities"; import { environmentPresentations } from "../state/presentation"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "../state/server"; import { environmentProjects } from "../state/projects"; import { threadEnvironment } from "../state/threads"; import { useThreadSearch } from "../state/queries"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { readEnvironmentScope } from "../state/session"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { canShareWithGuestAtom } from "./chat/guestShareAvailability"; +import { openGuestShareDialog } from "./chat/guestShareDialogState"; import { buildThreadRouteParams, resolveActiveThreadRouteRef } from "../threadRoutes"; import type { SidebarThreadSummary } from "../types"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; @@ -2388,6 +2391,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), + canShareWithGuest: appAtomRegistry.get( + canShareWithGuestAtom(threadRef.environmentId), + ), supports: { settlement: supportsSettlement, autoSettleOptOut: supportsAutoSettleOptOut, @@ -2484,6 +2490,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "rename": startThreadRename(threadRef, thread.title); return; + case "share-guest": + openGuestShareDialog(threadRef); + return; case "regenerate-title": { if (isRegeneratingTitle) return; const result = await updateThreadMetadata({ diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index edc1b8e2f3c6..9769fbce75e6 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -139,7 +139,11 @@ function EnvironmentNotifications({ let status = resolveSidebarThreadStatus(thread); if (status === "ready" && thread.latestRun?.status === "failed") status = "failed"; const attention = - status === "input" || status === "approval" || status === "failed" || status === "limited" + status === "input" || + status === "request" || + status === "approval" || + status === "failed" || + status === "limited" ? `${thread.latestRun?.runId ?? ""}:${status}` : null; const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); @@ -170,7 +174,9 @@ function EnvironmentNotifications({ ? "Usage limit reached" : status === "failed" ? "Thread failed" - : "Input needed"; + : status === "request" + ? "Guest request" + : "Input needed"; if (hasNotificationSound(mode)) { void playNotificationSound(kind, () => hasNotificationSound(getClientSettings().notificationMode), diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 56861a5d9a0d..c3d591354334 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -24,6 +24,9 @@ import { toastManager } from "../ui/toast"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; import { readLocalApi } from "~/localApi"; import { threadEnvironment } from "../../state/threads"; +import { serverEnvironment } from "../../state/server"; +import { canShareWithGuestAtom } from "./guestShareAvailability"; +import { useAtomValue } from "@effect/atom-react"; import { useOrchestrationCommand } from "../../state/use-orchestration-command"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { ProjectFavicon } from "../ProjectFavicon"; @@ -37,6 +40,7 @@ import { observeResize } from "~/lib/observeResize"; import { cn } from "~/lib/utils"; import { textDirection } from "~/textDirection"; import { useClientSettings } from "../../hooks/useSettings"; +import { GuestShareIndicator } from "./GuestShareControls"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -88,6 +92,7 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, AuthOrchestrationOperateScope, ); + const canShareWithGuest = useAtomValue(canShareWithGuestAtom(activeThreadEnvironmentId)); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -191,6 +196,7 @@ export const ChatHeader = memo(function ChatHeader({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, onStartRename: startRename, + canShareWithGuest, }); const titleButtonRef = useRef(null); const titleMenuTimerRef = useRef(null); @@ -406,6 +412,7 @@ export const ChatHeader = memo(function ChatHeader({ )} + {isServerThread ? : null} ); }); diff --git a/apps/web/src/components/chat/CommandOutputPanel.tsx b/apps/web/src/components/chat/CommandOutputPanel.tsx index cc631e76317b..d030351cb8de 100644 --- a/apps/web/src/components/chat/CommandOutputPanel.tsx +++ b/apps/web/src/components/chat/CommandOutputPanel.tsx @@ -1,3 +1,4 @@ +import type { CommandOutputView } from "@t3tools/client-runtime/state/command-output"; import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; import { TERMINAL_PALETTES, terminalOutputSpans } from "@t3tools/shared/terminalOutput"; import { useLayoutEffect, useRef } from "react"; @@ -6,6 +7,7 @@ import { useTheme } from "../../hooks/useTheme"; import { useServerConfigs } from "../../state/entities"; import { orchestrationEnvironment } from "../../state/orchestration"; import { useEnvironmentQuery } from "../../state/query"; +import type { TurnItemSource } from "./timelineRowContext"; // Within this many pixels of the end still counts as following the output. const FOLLOW_SLACK_PX = 8; @@ -19,19 +21,21 @@ export function useCommandOutputStreaming(environmentId: EnvironmentId): boolean /** * Output of one expanded command row: streams while the command runs, then - * shows its final output. Mount it only when `useCommandOutputStreaming` holds. + * shows its final output. Mount it only when `useCommandOutputStreaming` holds + * or a `source` serves the output. * It subscribes only while mounted, so collapsed rows and other threads receive nothing. */ export function CommandOutputPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly itemId: TurnItemId; + readonly source?: TurnItemSource["commandOutput"] | undefined; }) { - const { data, error } = useEnvironmentQuery( - orchestrationEnvironment.v2.commandOutput({ - environmentId: props.environmentId, - input: { threadId: props.threadId, itemId: props.itemId }, - }), + const input = { threadId: props.threadId, itemId: props.itemId }; + const { data, error } = useEnvironmentQuery( + props.source + ? props.source(input) + : orchestrationEnvironment.v2.commandOutput({ environmentId: props.environmentId, input }), ); const scrollRef = useRef(null); // Follow new output until the reader scrolls up; scrolling back down resumes. diff --git a/apps/web/src/components/chat/GuestMessageAttachments.tsx b/apps/web/src/components/chat/GuestMessageAttachments.tsx new file mode 100644 index 000000000000..e66fefb7d270 --- /dev/null +++ b/apps/web/src/components/chat/GuestMessageAttachments.tsx @@ -0,0 +1,102 @@ +import type { ChatAttachment } from "@t3tools/contracts"; +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; + +import { useTheme } from "~/hooks/useTheme"; +import { cn } from "~/lib/utils"; +import { type ChatFileAttachment, isFileAttachment, isImageAttachment } from "../../types"; +import { AttachmentImage } from "../media/AttachmentImage"; +import { PierreEntryIcon } from "./PierreEntryIcon"; + +const IMAGE_FRAME = + "aspect-[4/3] overflow-hidden rounded-lg border border-border/80 bg-background/70"; +const FILE_ROW = "flex min-w-0 items-center gap-2 py-1 text-sm"; + +/** + * A guest message's files, drawn like a sent message's: image thumbnails, then file rows. The + * owner opens them in the app; the guest loads them from the guest route. + */ +export function GuestMessageAttachments(props: { + readonly attachments: ReadonlyArray; + /** Where an image loads from; undefined while its URL is not ready. */ + readonly imageUrl: (attachment: ChatAttachment) => string | undefined; + readonly onOpenImage: (attachment: ChatAttachment) => void; + /** A file row as a link, or a button that calls `onOpenFile`. */ + readonly fileHref?: (attachment: ChatFileAttachment) => string; + readonly onOpenFile?: (attachment: ChatFileAttachment) => void; +}) { + const { resolvedTheme } = useTheme(); + const images = props.attachments.filter(isImageAttachment); + const files = props.attachments.filter(isFileAttachment); + if (images.length === 0 && files.length === 0) return null; + return ( + + ); +} diff --git a/apps/web/src/components/chat/GuestShareControls.tsx b/apps/web/src/components/chat/GuestShareControls.tsx new file mode 100644 index 000000000000..797cb0e5c52b --- /dev/null +++ b/apps/web/src/components/chat/GuestShareControls.tsx @@ -0,0 +1,326 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + type ChatAttachment, + type GuestMessageId, + type ScopedThreadRef, + type ThreadGuestShare, +} from "@t3tools/contracts"; +import { CheckIcon, CopyIcon, UserIcon } from "lucide-react"; +import { useState } from "react"; + +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useNowMinute } from "~/hooks/useNowMinute"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { useQuietThreadProjection } from "~/state/entities"; +import { serverEnvironment } from "~/state/server"; +import { useEnvironmentScope } from "~/state/session"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { cn } from "~/lib/utils"; +import { useOrchestrationCommand } from "~/state/use-orchestration-command"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { useAssetUrls } from "~/assets/assetUrls"; +import { type ChatFileAttachment, isImageAttachment } from "../../types"; +import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; +import { GuestMessageAttachments } from "./GuestMessageAttachments"; +import { GuestMessageName, guestBubbleClassName } from "./guestMessageStyle"; +import { guestShareDialogThreadAtom } from "./guestShareDialogState"; + +function useThreadGuestShare(threadRef: ScopedThreadRef | null): ThreadGuestShare | null { + return useQuietThreadProjection(threadRef)?.projection.thread.guestShare ?? null; +} + +export function GuestShareDialogHost() { + const threadRef = useAtomValue(guestShareDialogThreadAtom); + if (threadRef === null) return null; + return ( + appAtomRegistry.set(guestShareDialogThreadAtom, null)} + /> + ); +} + +type CreateState = + | { readonly status: "idle" } + | { readonly status: "creating" } + | { readonly status: "created"; readonly url: string } + | { readonly status: "failed"; readonly message: string }; + +function GuestShareDialog({ + threadRef, + onClose, +}: { + readonly threadRef: ScopedThreadRef; + readonly onClose: () => void; +}) { + const activeShare = useThreadGuestShare(threadRef); + const canCreate = useAtomValue( + serverEnvironment.createGuestShare.permissionAtom(threadRef.environmentId), + ); + const createGuestShare = useAtomCommand(serverEnvironment.createGuestShare, { + reportFailure: false, + }); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "guest link" }); + const [state, setState] = useState({ status: "idle" }); + + const create = async () => { + setState({ status: "creating" }); + const result = await createGuestShare({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + if (result._tag === "Success") { + setState({ status: "created", url: result.value.url }); + return; + } + if (isAtomCommandInterrupted(result)) { + setState({ status: "idle" }); + return; + } + const error = squashAtomCommandFailure(result); + setState({ + status: "failed", + message: error instanceof Error ? error.message : "Could not create a guest link.", + }); + }; + + return ( + { + if (!open && state.status !== "creating") onClose(); + }} + > + + + Share with guest + + The guest sees the thread as you do, including command output and the agent's + reasoning. Each message they send waits for you. + + + + {state.status === "created" ? ( +
+ event.currentTarget.select()} + /> + +
+ ) : ( +

+ {activeShare !== null + ? "A new link ends the current one." + : "The link works once and expires in 24 hours."} +

+ )} + {state.status === "failed" ? ( +

+ {state.message} +

+ ) : null} +
+ + {state.status === "created" ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} + +/** Header status for a shared thread, with the way to end the share. */ +export function GuestShareIndicator({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + const share = useThreadGuestShare(threadRef); + const closeGuestShare = useOrchestrationCommand(threadEnvironment.closeGuestShare, { + label: "stop guest share", + }); + const [closing, setClosing] = useState(false); + if (share === null) return null; + return ( +
+ + + +
+ ); +} + +function GuestShareStatus({ share }: { readonly share: ThreadGuestShare }) { + const nowMinute = useNowMinute(); + // The share stays on the thread after it expires, until the owner clears it. + if (share.expiresAt.slice(0, 16) <= nowMinute) return Guest link expired; + return ( + {share.guestName !== null ? `${share.guestName} is a guest` : "Guest link active"} + ); +} + +/** + * A guest's messages wait at the end of the timeline until the owner sends or dismisses each one. + * Sent ones then show in the timeline as normal user messages with the guest's name. + */ +export function GuestPendingMessages({ + threadRef, + onImageExpand, + onFileOpen, +}: { + readonly threadRef: ScopedThreadRef; + readonly onImageExpand: (preview: ExpandedImagePreview) => void; + readonly onFileOpen: (attachment: ChatFileAttachment) => void; +}) { + const share = useThreadGuestShare(threadRef); + const canOperate = useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope); + const resolveGuestMessage = useOrchestrationCommand(threadEnvironment.resolveGuestMessage, { + label: "resolve guest message", + }); + const [busyIds, setBusyIds] = useState>(new Set()); + const pending = share?.messages.filter((message) => message.status === "pending") ?? []; + // The files are the thread's attachments, which the owner's environment serves like any other. + const images = pending.flatMap((message) => message.attachments ?? []).filter(isImageAttachment); + const imageUrls = useAssetUrls( + threadRef.environmentId, + images.map((image) => ({ _tag: "attachment" as const, attachmentId: image.id })), + ); + const imageUrlById = new Map( + images.flatMap((image, index) => { + const url = imageUrls[index]; + return url ? [[image.id, url] as const] : []; + }), + ); + if (share === null || pending.length === 0) return null; + const guestName = share.guestName ?? "Guest"; + const openImage = (attachment: ChatAttachment) => { + const preview = buildExpandedImagePreview( + images.map((image) => ({ ...image, previewUrl: imageUrlById.get(image.id) ?? "" })), + attachment.id, + ); + if (preview) onImageExpand(preview); + }; + + const resolve = async (guestMessageId: GuestMessageId, decision: "send" | "dismiss") => { + setBusyIds((ids) => new Set(ids).add(guestMessageId)); + await resolveGuestMessage({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, guestMessageId, decision }, + }); + setBusyIds((ids) => { + const next = new Set(ids); + next.delete(guestMessageId); + return next; + }); + }; + + return ( +
    + {pending.map((message) => { + const busy = busyIds.has(message.id); + return ( +
  1. + +
    + imageUrlById.get(attachment.id)} + onOpenImage={openImage} + onOpenFile={onFileOpen} + /> + {message.text ? ( +

    + {message.text} +

    + ) : null} +
    + {canOperate ? ( +
    + + +
    + ) : null} +
  2. + ); + })} +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ee61f27e20c9..6da2d5c6f037 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -212,15 +212,17 @@ interface MessagesTimelineProps { paintOnly?: boolean; /** Off where another region already speaks the thread's status, like a subagent's bar. */ announceTurnStatus?: boolean; - onOpenTurnDiff: (runId: RunId, filePath?: string) => void; + onOpenTurnDiff?: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; + renderHtmlRender?: TimelineRowSharedState["renderHtmlRender"]; + turnItemSource?: TimelineRowSharedState["turnItemSource"]; parentThreadLink?: { readonly threadId: ThreadId; readonly title: string; } | null; /** Set on a workflow coordinator's thread: its script message renders as this opening. */ workflowOpening?: WorkflowOpening | null; - onForkFromRun: (input: { + onForkFromRun?: (input: { readonly sourceThreadId: ThreadId; readonly runId: RunId; }) => Promise; @@ -328,6 +330,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ announceTurnStatus = true, onOpenTurnDiff, onOpenThread, + renderHtmlRender, + turnItemSource, parentThreadLink = null, workflowOpening = null, onForkFromRun, @@ -931,6 +935,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ openPullRequest, onOpenTurnDiff, onOpenThread, + renderHtmlRender, + turnItemSource, workflowOpening, onForkFromRun, onRollbackCheckpoint, diff --git a/apps/web/src/components/chat/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index 15ea2318ba6c..98c7de52f1bc 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -119,12 +119,15 @@ export function QueuedRunsControl({ (message) => message.inputIntent === "queued_turn" && !acknowledgedMessageIds.has(message.id), ); const items = [ - ...queued.map(({ run, text, attachments }, serverIndex) => ({ + ...queued.map(({ run, text, attachments, context }, serverIndex) => ({ key: run.id, runId: run.id, messageId: run.userMessageId, serverIndex, text, + // A guest's message keeps the guest's words; the owner can cancel it, not rewrite it. + editable: context?.guest === undefined, + guestName: context?.guest?.name, attachments, thumbnails: attachments .filter((attachment) => attachment.type === "image") @@ -141,6 +144,8 @@ export function QueuedRunsControl({ messageId: null, serverIndex: null, text: message.text, + editable: true, + guestName: undefined, attachments: [] as ReadonlyArray, thumbnails: (message.attachments ?? []) .filter(isImageAttachment) @@ -202,7 +207,14 @@ export function QueuedRunsControl({ // moving the caret inside that draft. editLatest(repeat) { const latest = queued.at(-1); - if (!latest || props.editingRunId !== null || busyRunId !== null) return false; + if ( + !latest || + latest.context?.guest !== undefined || + props.editingRunId !== null || + busyRunId !== null + ) { + return false; + } if (!repeat) { setExpanded(true); props.onEditQueuedRun({ @@ -380,6 +392,9 @@ export function QueuedRunsControl({ ))} ) : null} + {item.guestName === undefined ? null : ( + {item.guestName} + )} }> {previewText} @@ -408,7 +423,9 @@ export function QueuedRunsControl({ size="icon-xs" variant="ghost-muted" aria-label="Edit queued message" - disabled={item.runId === null || busyRunId !== null} + disabled={ + item.runId === null || !item.editable || busyRunId !== null + } onClick={() => { if (item.runId !== null && item.messageId !== null) { props.onEditQueuedRun({ diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 6f99213057f9..89b52ae5c5cb 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -53,12 +53,14 @@ export function AssistantTimelineRow({ row }: { row: Extract - + {ctx.onOpenTurnDiff ? ( + + ) : null} {row.showAssistantMeta ? ( { setBusy(true); - void ctx - .onForkFromRun({ sourceThreadId: projectedItem.sourceThreadId, runId }) - .finally(() => setBusy(false)); + void onForkFromRun({ sourceThreadId: projectedItem.sourceThreadId, runId }).finally( + () => setBusy(false), + ); }} aria-label="Fork from this response" /> @@ -244,6 +247,9 @@ export function HtmlRenderTimelineRow({ const { onAppFullscreenChange } = ctx; const onFullscreenChange = (fullscreen: boolean) => onAppFullscreenChange(row.id, fullscreen); + if (ctx.renderHtmlRender) { + return
{ctx.renderHtmlRender(row)}
; + } return (
image.name.startsWith("preview-annotation-")); const revertTurnCount = row.revertTurnCount; const chippedAttachmentIds = new Set( @@ -427,13 +430,20 @@ function UserTimelineRow({ row }: { row: Extract ) : null} + {/* The row's heading already names the guest for screen readers. */} + {userMessage.guestName !== undefined ? ( +
+ +
+ ) : null} {row.message.inputIntent === "steer" || row.message.inputIntent === "promoted_queued_to_steer" ? ( ) : null}
- You + + {userMessage.guestName === undefined ? "You" : `${userMessage.guestName} (guest)`} + {(regularImages.length > 0 || userVideos.length > 0) && (
{regularImages.map((image) => ( diff --git a/apps/web/src/components/chat/TimelineV2EventRow.tsx b/apps/web/src/components/chat/TimelineV2EventRow.tsx index a4d2adf902f3..9a64ca0f0315 100644 --- a/apps/web/src/components/chat/TimelineV2EventRow.tsx +++ b/apps/web/src/components/chat/TimelineV2EventRow.tsx @@ -126,6 +126,7 @@ export function V2EventTimelineRow({ row }: { row: Extract
@@ -201,6 +202,7 @@ export function V2EventTimelineRow({ row }: { row: Extract
diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 457e960e9fb6..76daf03c1067 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -534,6 +534,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { onOpenTurnDiff={ctx.onOpenTurnDiff} onRollbackCheckpoint={ctx.onRollbackCheckpoint} onImageExpand={ctx.onImageExpand} + source={ctx.turnItemSource} /> ) : ( <> @@ -545,6 +546,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { projectedItem={workEntry.projectedItem} environmentId={ctx.activeThreadEnvironmentId} onImageExpand={onImageExpand} + source={ctx.turnItemSource} /> ) : null} diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index a93e8a5b611b..b0727a1599f5 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -31,6 +31,7 @@ import { CommandOutputPanel, useCommandOutputStreaming } from "./CommandOutputPa import { resolveExternalWebLinkHref } from "./externalLinkContextMenu"; import type { ExpandedImagePreview } from "./ExpandedImagePreview"; import { ShellCommandBlock } from "./ShellCommandBlock"; +import type { TurnItemSource } from "./timelineRowContext"; interface V2ItemInspectorProps { readonly projectedItem: OrchestrationV2ProjectedTurnItem; @@ -38,12 +39,13 @@ interface V2ItemInspectorProps { readonly cwd?: string | undefined; readonly workspaceRoot?: string | undefined; readonly onOpenThread: (threadId: ThreadId) => void; - readonly onOpenTurnDiff: (runId: RunId, filePath?: string) => void; + readonly onOpenTurnDiff?: ((runId: RunId, filePath?: string) => void) | undefined; readonly onRollbackCheckpoint?: (input: { readonly checkpointId: string; readonly scopeId: string; }) => void; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + readonly source?: TurnItemSource | undefined; } function SyntaxTokens({ @@ -112,6 +114,7 @@ function StructuredValue({ function useFetchedTurnItem( projectedItem: OrchestrationV2ProjectedTurnItem, environmentId: EnvironmentId, + source: TurnItemSource | undefined, skip = false, ) { const wireItem = projectedItem.item; @@ -125,6 +128,7 @@ function useFetchedTurnItem( revision: turnItemDetailRevision(wireItem), } : null, + source?.detail, ); const fetchedItem = detail.data?.item; const item = fetchedItem?.type === wireItem.type ? fetchedItem : wireItem; @@ -188,8 +192,9 @@ export function FetchedToolOutput(props: { readonly projectedItem: OrchestrationV2ProjectedTurnItem; readonly environmentId: EnvironmentId; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + readonly source?: TurnItemSource | undefined; }) { - const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId); + const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId, props.source); return (
@@ -236,10 +241,16 @@ function ToolCallBody( export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) { // Command rows stream their output, including the final output, from the server. + const environmentStreamsOutput = useCommandOutputStreaming(props.environmentId); const streamsOutput = - useCommandOutputStreaming(props.environmentId) && + (props.source !== undefined || environmentStreamsOutput) && props.projectedItem.item.type === "command_execution"; - const fetched = useFetchedTurnItem(props.projectedItem, props.environmentId, streamsOutput); + const fetched = useFetchedTurnItem( + props.projectedItem, + props.environmentId, + props.source, + streamsOutput, + ); const item = fetched.item; const outputState = fetched.output; const support = useV2ItemSupport({ @@ -275,6 +286,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec environmentId={props.environmentId} threadId={props.projectedItem.sourceThreadId} itemId={props.projectedItem.sourceItemId} + source={props.source?.commandOutput} /> ) : undefined } @@ -293,11 +305,11 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec -{formatDiffCount(item.deletions ?? 0)} ) : null} - {item.runId !== null ? ( + {item.runId !== null && props.onOpenTurnDiff ? ( @@ -386,7 +398,15 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec {item.type === "approval_request" ? : null} {item.type === "user_input_request" ? ( - question.question).join("\n\n")} /> + + [question.question, ...question.options.map((option) => `• ${option.label}`)].join( + "\n", + ), + ) + .join("\n\n")} + /> ) : null} {item.type === "notification" ? : null} {item.type === "system_notice" ? : null} diff --git a/apps/web/src/components/chat/guestMessageStyle.tsx b/apps/web/src/components/chat/guestMessageStyle.tsx new file mode 100644 index 000000000000..90e49994f8e8 --- /dev/null +++ b/apps/web/src/components/chat/guestMessageStyle.tsx @@ -0,0 +1,14 @@ +/** Bubble classes for a guest's message. A pending one has a dashed edge and no fill. */ +export function guestBubbleClassName(pending: boolean): string { + return pending ? "border border-dashed border-guest/50 bg-transparent" : "bg-guest-surface"; +} + +/** The guest's name above their message, where "Sent by T3 Code" sits for server messages. */ +export function GuestMessageName({ name }: { readonly name: string }) { + return ( +

+ {name} + (guest) +

+ ); +} diff --git a/apps/web/src/components/chat/guestShareAvailability.ts b/apps/web/src/components/chat/guestShareAvailability.ts new file mode 100644 index 000000000000..618b8f042e46 --- /dev/null +++ b/apps/web/src/components/chat/guestShareAvailability.ts @@ -0,0 +1,18 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { environmentPresentations } from "~/state/presentation"; +import { serverEnvironment } from "~/state/server"; + +/** + * Whether this environment offers "Share with guest": the user may create links there, and its + * Guest links beta is on (Settings > Beta). The server refuses links while the beta is off. + */ +export const canShareWithGuestAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get) => + get(serverEnvironment.createGuestShare.permissionAtom(environmentId)) && + get(environmentPresentations.serverConfigsAtom).get(environmentId)?.settings + .guestSharingEnabled === true, + ).pipe(Atom.withLabel(`guest-share:available:${environmentId}`)), +); diff --git a/apps/web/src/components/chat/guestShareDialogState.ts b/apps/web/src/components/chat/guestShareDialogState.ts new file mode 100644 index 000000000000..f8d0e5d095f7 --- /dev/null +++ b/apps/web/src/components/chat/guestShareDialogState.ts @@ -0,0 +1,17 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +/** + * Which thread has the guest share dialog open. The thread menu and the command palette both + * close as their command runs, so the chat view renders the dialog once. + */ +export const guestShareDialogThreadAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("guest-share:dialog-thread"), +); + +export function openGuestShareDialog(threadRef: ScopedThreadRef): void { + appAtomRegistry.set(guestShareDialogThreadAtom, threadRef); +} diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index fdfb833ecc6a..aa0c919ab10c 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -8,9 +8,12 @@ import { type ServerProviderSkill, type RunId, ThreadId, + type TurnItemId, } from "@t3tools/contracts"; +import type { AsyncResult, Atom } from "effect/reactivity"; +import type { CommandOutputView } from "@t3tools/client-runtime/state/command-output"; import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; -import { createContext, type MouseEvent } from "react"; +import { createContext, type MouseEvent, type ReactNode } from "react"; import { type LegendListRef } from "@legendapp/list/react"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { type ChatFileAttachment } from "../../types"; @@ -18,8 +21,21 @@ import { ExpandedImagePreview } from "./ExpandedImagePreview"; import { type AssistantCitationTarget } from "./AssistantCitationSource"; import { type MessagesTimelineRow, type WorkGroupScrollAnchor } from "./MessagesTimeline.logic"; import { type TimestampFormat } from "@t3tools/contracts/settings"; +import type { TurnItemDetailSource } from "../../state/queries"; import { type HandoffTimelineRun } from "./V2LifecycleRow"; +/** + * Where an expanded row reads an item's detail and a command's output. The owner's rows read them + * from the environment; the guest page reads them through its own routes. + */ +export interface TurnItemSource { + readonly detail: TurnItemDetailSource; + readonly commandOutput: (target: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) => Atom.Atom>; +} + export interface TimelineRowSharedState { citationRequest: AssistantCitationTarget | null; listRef: React.RefObject; @@ -40,13 +56,21 @@ export interface TimelineRowSharedState { findActive: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; displayThreadKey?: string; - onOpenTurnDiff: (runId: RunId, filePath?: string) => void; + /** Unset where there is no diff to open, so the timeline shows no "Open diff" button. */ + onOpenTurnDiff: ((runId: RunId, filePath?: string) => void) | undefined; + /** + * Draws a published HTML visual. Unset, the row loads it from the environment; the guest page + * loads it through its own route. + */ + renderHtmlRender: ((row: Extract) => ReactNode) | undefined; + /** Unset, expanded rows read detail and command output from the environment. */ + turnItemSource: TurnItemSource | undefined; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; workflowOpening: WorkflowOpening | null; - onForkFromRun: (input: { - readonly sourceThreadId: ThreadId; - readonly runId: RunId; - }) => Promise; + /** Absent hides "Fork from this response", as in the guest view. */ + onForkFromRun: + | ((input: { readonly sourceThreadId: ThreadId; readonly runId: RunId }) => Promise) + | undefined; onRollbackCheckpoint: (input: { readonly checkpointId: string; readonly scopeId: string; diff --git a/apps/web/src/components/guest/GuestHtmlRender.tsx b/apps/web/src/components/guest/GuestHtmlRender.tsx new file mode 100644 index 000000000000..75b7445ac6e8 --- /dev/null +++ b/apps/web/src/components/guest/GuestHtmlRender.tsx @@ -0,0 +1,93 @@ +import { GUEST_RENDER_ROUTE, GUEST_SHARE_PARAM } from "@t3tools/contracts"; +import { + type HtmlRenderReference, + HTML_RENDER_COLUMN_WIDTH, + htmlRenderFrameHeight, + htmlRenderResult, + htmlRenderThemeFragment, + htmlRenderThemeMessage, + readHtmlRenderContentHeight, + readHtmlRenderLinkRequest, +} from "@t3tools/shared/htmlRender"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; +import { observeResize } from "~/lib/observeResize"; + +/** + * A visual the agent published, as the guest sees it. The server sends it with a sandbox CSP and + * the frame is sandboxed too, so the page runs scripts in an opaque origin. It can size itself + * and ask to open a web link; it cannot reach the guest's session. + */ +export function GuestHtmlRender({ + itemId, + reference, + shareId, +}: { + readonly itemId: string; + readonly reference: HtmlRenderReference; + readonly shareId: string; +}) { + const theme = useHtmlRenderTheme(); + const boxRef = useRef(null); + const frameRef = useRef(null); + const [width, setWidth] = useState(HTML_RENDER_COLUMN_WIDTH); + const [contentHeight, setContentHeight] = useState(); + // The theme rides in the fragment for the first paint; later changes are posted. + const [src] = useState( + () => + `${GUEST_RENDER_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&item=${encodeURIComponent(itemId)}${htmlRenderThemeFragment(theme)}`, + ); + useLayoutEffect(() => { + const box = boxRef.current; + if (!box) return; + setWidth(box.clientWidth); + return observeResize(box, ([entry]) => { + if (entry) setWidth(entry.contentRect.width); + }); + }, []); + useEffect(() => { + frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); + }, [theme]); + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const frame = frameRef.current; + if (frame === null || event.source !== frame.contentWindow) return; + const height = readHtmlRenderContentHeight(event.data); + if (height !== undefined) { + setContentHeight(height); + return; + } + // Only while the reader is using this frame, so a page cannot open links on its own. + const link = readHtmlRenderLinkRequest(event.data); + if ( + link === undefined || + document.activeElement !== frame || + navigator.userActivation?.isActive === false + ) { + return; + } + window.open(link.url, "_blank", "noopener,noreferrer"); + frame.contentWindow?.postMessage(htmlRenderResult(link.id), "*"); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + return ( +
+