diff --git a/apps/mobile/src/components/GitHubDiscussionIcon.tsx b/apps/mobile/src/components/GitHubDiscussionIcon.tsx new file mode 100644 index 0000000000000..e463561ad75d3 --- /dev/null +++ b/apps/mobile/src/components/GitHubDiscussionIcon.tsx @@ -0,0 +1,24 @@ +import type { ColorValue } from "react-native"; +import Svg, { Path } from "react-native-svg"; +import { withUniwind } from "uniwind"; + +const ThemedPath = withUniwind(Path); + +// GitHub Octicons comment-discussion-16. Copyright (c) 2026 GitHub Inc., MIT. +// https://github.com/primer/octicons/blob/main/LICENSE +export function GitHubDiscussionIcon(props: { + readonly size: number; + readonly color?: ColorValue; + readonly colorClassName?: string; +}) { + return ( + + + + ); +} diff --git a/apps/mobile/src/features/threads/GitHubReplySnooze.tsx b/apps/mobile/src/features/threads/GitHubReplySnooze.tsx new file mode 100644 index 0000000000000..e8c1ab4feca6b --- /dev/null +++ b/apps/mobile/src/features/threads/GitHubReplySnooze.tsx @@ -0,0 +1,110 @@ +import { + githubReplyConversationLabel, + githubReplyConversationsForCondition, + githubReplySnoozeFailureMessage, + type GitHubReplyConversation, +} from "@t3tools/client-runtime/github-reply-conversations"; +import type { GitHubReplyWakeCondition } from "@t3tools/contracts"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentServerConfigsAtom } from "../../state/server"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import type { MenuAction } from "@react-native-menu/menu"; +import { useCallback, useRef, useState } from "react"; +import { Alert, Linking, Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function githubReplySnoozeMenuAction( + conversations: ReadonlyArray, + wakeCondition?: GitHubReplyWakeCondition, +): MenuAction { + conversations = githubReplyConversationsForCondition(conversations, wakeCondition); + const changesRequested = wakeCondition === "changes-requested"; + const prefix = changesRequested ? "snooze:github:changes-requested" : "snooze:github"; + const only = conversations.length === 1 ? conversations[0] : undefined; + return { + id: only ? `${prefix}:${only.url}` : prefix, + title: changesRequested ? "Until changes are requested" : "Until a GitHub reply", + ...(conversations.length === 0 + ? { + subtitle: changesRequested + ? "Link an open GitHub PR first" + : "Link a GitHub PR or discussion first", + attributes: { disabled: true }, + } + : {}), + ...(conversations.length > 1 + ? { + subactions: conversations.map((conversation) => ({ + id: `${prefix}:${conversation.url}`, + title: githubReplyConversationLabel(conversation), + })), + } + : {}), + }; +} + +export function useGitHubReplySnooze(thread: EnvironmentThreadShell) { + const snooze = useAtomCommand(threadEnvironment.snooze); + const [pending, setPending] = useState(false); + const pendingRef = useRef(false); + const snoozeUrl = useCallback( + (url: string, wakeCondition?: GitHubReplyWakeCondition) => { + if ( + pendingRef.current || + (wakeCondition === "changes-requested" && + appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId)?.environment + .capabilities.threadGitHubChangesRequestedSnooze !== true) + ) + return; + pendingRef.current = true; + setPending(true); + void snooze({ + environmentId: thread.environmentId, + input: { threadId: thread.id, url, wakeCondition }, + }).then((result) => { + pendingRef.current = false; + setPending(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + Alert.alert("Could not snooze thread", githubReplySnoozeFailureMessage); + } + }); + }, + [snooze, thread.environmentId, thread.id], + ); + return { pending, snoozeUrl }; +} + +export function GitHubReplySnoozeCard({ thread }: { readonly thread: EnvironmentThreadShell }) { + const unsnooze = useAtomCommand(threadEnvironment.unsnooze); + const watch = thread.githubReplySnooze; + if (!watch) return null; + return ( + + + {watch.wakeCondition === "changes-requested" + ? "Waiting for changes to be requested" + : "Waiting for a GitHub reply"} + + + void Linking.openURL(watch.url)}> + View + + + + void unsnooze({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + } + > + Wake now + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index b6930deb5e864..cd7f131e98d8f 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import { GitHubReplySnoozeCard } from "./GitHubReplySnooze"; import { useAtomValue } from "@effect/atom-react"; import { useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; @@ -1238,6 +1239,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread /> ) : null} + onSettleThread(thread), [onSettleThread, thread]); const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false); + const { snoozeUrl } = useGitHubReplySnooze(thread); + const githubConversations = useMemo(() => eligibleThreadGitHubConversations(thread), [thread]); + const githubSnoozeSupported = + appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId)?.environment + .capabilities.threadGitHubReplySnooze === true; + const githubChangesRequestedSupported = + appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId)?.environment + .capabilities.threadGitHubChangesRequestedSnooze === true; // A recycled cell reassigns this mounted row to a different thread without // remounting it, and the render closure stops running while list equality // says the item is unchanged — so any row-local UI state must be dismissed @@ -698,17 +719,28 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), [props.snoozePresetMinute, swipeActions.secondary], ); - const snoozePresetActions = useMemo( - () => [ + const snoozePresetActions = useMemo(() => { + const githubActions = githubSnoozeSupported + ? [ + githubReplySnoozeMenuAction(githubConversations), + ...(githubChangesRequestedSupported + ? [githubReplySnoozeMenuAction(githubConversations, "changes-requested")] + : []), + ] + : []; + return [ ...snoozePresets.map((preset) => ({ id: `snooze:${preset.id}`, title: preset.label, subtitle: preset.whenLabel, })), + // ios draws dividers around inline groups; android keeps its flat menu. + ...(Platform.OS === "ios" && githubActions.length > 0 + ? [{ title: "", displayInline: true, subactions: githubActions }] + : githubActions), { id: "snooze:custom", title: "Custom…" }, - ], - [snoozePresets], - ); + ]; + }, [snoozePresets, githubSnoozeSupported, githubChangesRequestedSupported, githubConversations]); // Pinned cards keep the full lifecycle menu; only the pin item flips to // Unpin. (Settling a pinned thread clears the pin server-side; snoozing // hides the card until wake with the pin intact.) @@ -863,6 +895,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { copyTextWithHaptic(thread.id, { target: "thread-id" }); } if (nativeEvent.event === "delete") handleDelete(); + if (nativeEvent.event.startsWith("snooze:github:")) { + const choice = githubReplySnoozeChoice(nativeEvent.event, githubConversations); + if (choice) snoozeUrl(choice.url, choice.wakeCondition); + return; + } if (nativeEvent.event === "snooze:custom") { setCustomSnoozeOpen(true); return; @@ -890,6 +927,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handlePin, handleSettle, handleSnooze, + githubConversations, + snoozeUrl, handleSetAutoSettle, handleUnpin, handleUnsettle, @@ -996,6 +1035,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { type="monochrome" /> ) : null} + {showUnreadReplyDot ? ( + + ) : null} {statusLabel ? ( + ); + } if (props.icon === "t3-code") { return ( @@ -1216,7 +1231,13 @@ export function ThreadWorkGroupToggle(props: { readonly iconSubtleColor: import("react-native").ColorValue; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; - readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain"; + readonly summaryToolIcon?: + | "browser" + | "device" + | "t3-code" + | "pull-request" + | "discussion" + | "brain"; readonly themeAppearance: "light" | "dark"; readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; readonly toolIcon?: ToolActivityIcon; @@ -1549,8 +1570,10 @@ function ToolActivityImage(props: { ); } -function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { +function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): WorkContentIcon { switch (kind) { + case "discussion": + return "discussion"; case "pull-request": case "link-pr": case "unlink-pr": diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index d5946e62ca159..493a0bb32ce77 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -199,6 +199,24 @@ describe("resolveThreadListV2Status", () => { }, ); + it.each(["reply", "error"] as const)("marks a %s wake unread without a run", (type) => { + const thread = makeThread({ + id: ThreadId.make("reply-wake"), + title: "Reply wake", + latestRun: null, + lastVisitedAt: "2026-06-01T23:59:00.000Z", + githubReplyNotice: { + type, + receivedAt: NOW, + url: "https://github.com/team/repo/pull/1", + text: "Feedback", + }, + }); + expect(threadHasUnseenCompletion(thread)).toBe(true); + expect(threadHasUnseenCompletion({ ...thread, lastVisitedAt: null })).toBe(true); + expect(threadHasUnseenCompletion({ ...thread, lastVisitedAt: NOW })).toBe(false); + }); + it("resolves ready for quiescent threads", () => { expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( "ready", diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index b5aee8fb4b225..be02bb4aa512d 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -2,6 +2,7 @@ import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests import { canSnooze, effectiveSnoozed, + threadHasUnseenActivity, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, resolveSnoozePresets, @@ -162,24 +163,16 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** - * Completed-but-not-yet-seen, mirroring the web sidebar's + * Unseen completions and GitHub wakes, mirroring the web sidebar's * hasUnseenCompletion. The visited watermark is server state * (thread.lastVisitedAt), so the marker agrees across web and mobile. - * Never-visited threads count as read — a fresh environment must not light - * up its whole history — and pre-tracking servers (field absent) never - * report unread. + * Completion history without a visit watermark stays read. A GitHub notice + * counts as unread even if the thread has no runs or prior visits. */ export function threadHasUnseenCompletion( - thread: Pick, + thread: Pick, ): boolean { - const completedAt = thread.latestRun?.completedAt; - if (!completedAt) return false; - const completedAtMs = Date.parse(completedAt); - if (Number.isNaN(completedAtMs)) return false; - if (!thread.lastVisitedAt) return false; - const lastVisitedAtMs = Date.parse(thread.lastVisitedAt); - if (Number.isNaN(lastVisitedAtMs)) return true; - return completedAtMs > lastVisitedAtMs; + return threadHasUnseenActivity(thread); } export function resolveThreadListV2Status( @@ -465,7 +458,11 @@ function resolveThreadListV2ItemTimeLabel( if (showSnoozeWakeLabel) return ""; if ( variant === "card" && - (resolveThreadListV2Status(thread) !== "ready" || threadHasUnseenCompletion(thread)) + (resolveThreadListV2Status(thread) !== "ready" || + threadHasUnseenCompletion({ + latestRun: thread.latestRun, + lastVisitedAt: thread.lastVisitedAt, + })) ) return ""; const settledTimestamp = @@ -514,8 +511,11 @@ export function buildThreadListV2ListItems(input: { // An empty set is treated as absent so `?.` skips building the key. const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined; const threadItems = input.items.map((item): ThreadListV2ListItem => { - const snoozeWakeLabelText = - item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined + const snoozeWakeLabelText = item.thread.githubReplySnooze + ? item.thread.githubReplySnooze.wakeCondition === "changes-requested" + ? "Until change request" + : "Until reply" + : item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) : undefined; // The minute clock belongs on the item, not the list's extraData, so the diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d06af828ee11f..288d5bb3d79de 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -210,7 +210,7 @@ type ThreadFeedEntryContent = readonly summaryKind: ToolGroupSummaryKind; readonly toolSurface?: WorkLogPresentationEntry["toolSurface"]; readonly toolIcon?: WorkLogPresentationEntry["toolIcon"]; - readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; + readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "discussion"; readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 3624caebb0767..68558fdf0c8f6 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -84,6 +84,7 @@ function threadDetailToShell( worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest ?? null, pullRequests: thread.pullRequests, + discussions: thread.discussions, branchPullRequest: thread.branchPullRequest ?? null, activeProviderThreadId: thread.activeProviderThreadId, lineage: thread.lineage, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 6381547468ae3..f59a064e3ace5 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -233,6 +233,8 @@ export const make = Effect.gen(function* () { threadRestartContinuation: true, projectSettingsOverrides: true, threadSnooze: true, + threadGitHubReplySnooze: true, + threadGitHubChangesRequestedSnooze: true, environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, @@ -244,6 +246,7 @@ export const make = Effect.gen(function* () { threadTitleRegeneration: true, threadVisitedTracking: true, threadPullRequests: true, + threadDiscussions: true, threadPullRequestWatch: true, pullRequestStackActions: true, threadPullRequestLinking: true, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index f4da6e051a7c3..195019c23aed6 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1,3 +1,6 @@ +import * as DiscussionsHandlers from "./toolkits/discussions/handlers.ts"; +import { DiscussionsToolkit } from "./toolkits/discussions/tools.ts"; +import * as ThreadDiscussionMcpService from "./ThreadDiscussionMcpService.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -833,6 +836,11 @@ const layerDeviceStandardToolkitRegistration = toolkitRegistration( DeviceHandlers.layerStandard, ); +const layerDiscussionsToolkit = toolkitRegistration( + DiscussionsToolkit, + DiscussionsHandlers.layer, +).pipe(Layer.provide(ThreadDiscussionMcpService.layer)); + const layerDeviceScreenshotRegistration = imageToolRegistration( registerDeviceScreenshot(), DeviceHandlers.layerScreenshot, @@ -860,6 +868,7 @@ export const layer = Layer.mergeAll( layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, layerPullRequestsToolkit, + layerDiscussionsToolkit, layerDeviceToolkit, layerHtmlToolkit, ).pipe(Layer.provideMerge(layerMcpTransport)); diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts index 52458c8401e89..4c83194da8c08 100644 --- a/apps/server/src/mcp/McpToolAccess.race.test.ts +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -22,6 +22,9 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -40,6 +43,14 @@ const adapter = { openSession: () => Effect.die("No provider process needed for metadata commands"), } as ProviderAdapterV2Shape; const layerDatabase = SqlitePersistence.layerMemory; +const layerThreadManagement = ThreadManagement.layer.pipe( + Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})), + Layer.provide( + Layer.mergeAll(CommandReceiptStore.layer, ProjectStore.layer).pipe( + Layer.provide(layerDatabase), + ), + ), +); const layerOrchestrator = Layer.mergeAll( layerDatabase, ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), @@ -184,7 +195,7 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { const shell = yield* projections.getThreadShell(threadId); assert.equal(shell?.runtimeMode, "full-access"); assert.equal(shell?.title, "Before"); - }).pipe(Effect.provide(ThreadManagement.layer)), + }).pipe(Effect.provide(layerThreadManagement)), ); it.effect("lets the write through when the thread stays within the caller's modes", () => @@ -195,6 +206,6 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { const outcome = yield* renameRacingTheUser(threadId, false); assert.deepEqual(outcome, { renamed: true }); assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent"); - }).pipe(Effect.provide(ThreadManagement.layer)), + }).pipe(Effect.provide(layerThreadManagement)), ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index f4aa8bafbe666..af078105b6540 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1,3 +1,6 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; @@ -612,7 +615,16 @@ describe("orchestrator MCP toolkit", () => { ).pipe(Layer.provide(layerContinuationProbe)); const layerOrchestration = Layer.merge( layerOrchestrator, - ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)), + ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), + Layer.provide(layerOrchestrator), + ), ); const layerProviderRegistry = ProviderRegistryMock.layer([ makeProviderSnapshot({ @@ -3800,7 +3812,16 @@ describe("orchestrator MCP toolkit", () => { ); const layerOrchestration = Layer.merge( layerOrchestrator, - ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)), + ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), + Layer.provide(layerOrchestrator), + ), ); const layerProviderRegistry = ProviderRegistryMock.layer([ makeProviderSnapshot({ diff --git a/apps/server/src/mcp/ThreadDiscussionMcpService.test.ts b/apps/server/src/mcp/ThreadDiscussionMcpService.test.ts new file mode 100644 index 0000000000000..a9adf4a0eebfc --- /dev/null +++ b/apps/server/src/mcp/ThreadDiscussionMcpService.test.ts @@ -0,0 +1,224 @@ +import { assert, it } from "@effect/vitest"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ThreadDiscussionService from "../orchestration-v2/ThreadDiscussionService.ts"; +import { v2PullRequestThread } from "../orchestration-v2/testkit/pullRequestFixtures.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import * as ThreadDiscussionMcpService from "./ThreadDiscussionMcpService.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpToolAccess from "./McpToolAccess.ts"; +import * as DiscussionsHandlers from "./toolkits/discussions/handlers.ts"; +import { DiscussionsToolkit } from "./toolkits/discussions/tools.ts"; + +const threadId = ThreadId.make("caller"); +const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("env"), + capabilities: new Set(["pull-requests"]), + issuedAt: 1, + requestNamespace: "session", + thread: { + threadId, + providerSessionId: "session", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, +}; +const thread = ( + id: ThreadId, + overrides: Partial = {}, +): OrchestrationV2ThreadShell => ({ + ...v2PullRequestThread({ + id, + projectId: ProjectId.make("project"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestUserMessageAt: null, + createdAt: "2026-10-01T12:00:00Z", + updatedAt: "2026-10-01T12:00:00Z", + archivedAt: null, + settledAt: null, + settledOverride: null, + }), + ...overrides, +}); + +const harness = Effect.fn("discussionMcpTest.harness")(function* ( + threads: ReadonlyArray, +) { + const targets = yield* Ref.make>([]); + const record = (id: ThreadId) => Ref.update(targets, (ids) => [...ids, id]); + const identity = { + host: "github.com" as const, + repository: "team/repo", + number: 1, + url: "https://github.com/team/repo/discussions/1", + }; + const service = yield* ThreadDiscussionMcpService.ThreadDiscussionMcpService.pipe( + Effect.provide( + ThreadDiscussionMcpService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadShell: (id) => + Effect.succeed(threads.find((thread) => thread.id === id) ?? null), + }), + Layer.mock(ThreadDiscussionService.ThreadDiscussionService)({ + link: (input) => + record(input.threadId).pipe(Effect.as({ ...identity, alreadyLinked: false })), + unlink: (input) => + record(input.threadId).pipe(Effect.as({ ...identity, wasLinked: true })), + list: (id) => record(id).pipe(Effect.as({ discussions: [] })), + }), + ), + ), + ), + ), + ); + const toolkit = yield* DiscussionsToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(DiscussionsHandlers.layer).pipe( + Layer.provide( + Layer.succeed(ThreadDiscussionMcpService.ThreadDiscussionMcpService, service), + ), + ), + ), + ); + const call = ( + name: Name, + input: Parameters>[1], + invocation: McpInvocationScope = scope, + ) => + toolkit.handle(name, input).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(ThreadDiscussionMcpService.ThreadDiscussionMcpService, service), + Effect.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => + Effect.succeed(threads.find((thread) => thread.id === id) ?? null), + }), + ), + ); + return { service, targets, call }; +}); + +it.effect("uses the calling thread for link, list and unlink", () => + Effect.gen(function* () { + const { service, targets } = yield* harness([thread(threadId)]); + yield* service.link(scope, { repository: "team/repo", number: 1 }); + yield* service.list(scope, {}); + yield* service.unlink(scope, { repository: "team/repo", number: 1 }); + assert.deepEqual(yield* Ref.get(targets), [threadId, threadId, threadId]); + }), +); + +it.effect("requires the PR capability and an explicit target for external clients", () => + Effect.gen(function* () { + const { service, targets } = yield* harness([thread(threadId)]); + assert.equal( + (yield* service.list({ ...scope, capabilities: new Set() }, {}).pipe(Effect.flip))._tag, + "McpCapabilityUnavailableError", + ); + assert.include( + (yield* service.list({ ...scope, thread: undefined }, {}).pipe(Effect.flip)).message, + "Pass threadId", + ); + assert.include( + (yield* service.list(scope, { threadId: ThreadId.make("missing") }).pipe(Effect.flip)) + .message, + "not found", + ); + assert.deepEqual(yield* Ref.get(targets), []); + }), +); + +it.effect( + "blocks cross-thread mutations without an owned active run or sufficient permission", + () => + Effect.gen(function* () { + const other = ThreadId.make("other"); + const { call, targets } = yield* harness([thread(threadId), thread(other)]); + assert.propertyVal( + yield* call("link_discussion", { + threadId: other, + repository: "team/repo", + number: 1, + }).pipe(Effect.flip), + "code", + "parent_not_active", + ); + assert.propertyVal( + yield* call( + "unlink_discussion", + { threadId: other, repository: "team/repo", number: 1 }, + { ...scope, thread: undefined }, + ).pipe(Effect.flip), + "code", + "runtime_mode_escalation_denied", + ); + assert.deepEqual(yield* Ref.get(targets), []); + const active = yield* harness([ + thread(threadId, { activeRunId: RunId.make("run"), runtimeMode: "approval-required" }), + thread(other), + ]); + assert.propertyVal( + yield* active + .call("link_discussion", { threadId: other, repository: "team/repo", number: 1 }) + .pipe(Effect.flip), + "code", + "runtime_mode_escalation_denied", + ); + }), +); + +it.effect("allows an owned active caller to link another thread within its permissions", () => + Effect.gen(function* () { + const other = ThreadId.make("other"); + const { call, targets } = yield* harness([ + thread(threadId, { activeRunId: RunId.make("run") }), + thread(other), + ]); + yield* call("link_discussion", { threadId: other, repository: "team/repo", number: 1 }); + assert.deepEqual(yield* Ref.get(targets), [other]); + }), +); + +it.effect("read-only clients can list links but cannot link or unlink discussions", () => + Effect.gen(function* () { + const { call, targets } = yield* harness([thread(threadId)]); + const readOnly: McpInvocationScope = { + ...scope, + thread: undefined, + client: { sessionId: "reader", label: "Reader", access: "read-only" }, + }; + for (const name of ["link_discussion", "unlink_discussion"] as const) { + const error = yield* call( + name, + { threadId, repository: "team/repo", number: 1 }, + readOnly, + ).pipe(Effect.flip); + assert.propertyVal(error, "code", "capability_denied"); + } + assert.deepEqual(yield* Ref.get(targets), []); + yield* call("list_thread_discussions", { threadId }, readOnly); + assert.deepEqual(yield* Ref.get(targets), [threadId]); + }), +); diff --git a/apps/server/src/mcp/ThreadDiscussionMcpService.ts b/apps/server/src/mcp/ThreadDiscussionMcpService.ts new file mode 100644 index 0000000000000..d52a9d030807d --- /dev/null +++ b/apps/server/src/mcp/ThreadDiscussionMcpService.ts @@ -0,0 +1,95 @@ +import { DiscussionOperationError, type ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ThreadDiscussionService from "../orchestration-v2/ThreadDiscussionService.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; + +type TargetInput = Omit & { + readonly threadId?: ThreadId | undefined; +}; + +type Failure = + | DiscussionOperationError + | import("@t3tools/contracts").McpCapabilityUnavailableError; + +export class ThreadDiscussionMcpService extends Context.Service< + ThreadDiscussionMcpService, + { + readonly link: ( + scope: McpInvocationContext.McpInvocationScope, + input: TargetInput, + ) => Effect.Effect< + Effect.Success< + ReturnType + >, + Failure + >; + readonly unlink: ( + scope: McpInvocationContext.McpInvocationScope, + input: TargetInput, + ) => Effect.Effect< + Effect.Success< + ReturnType + >, + Failure + >; + readonly list: ( + scope: McpInvocationContext.McpInvocationScope, + input: { readonly threadId?: ThreadId | undefined }, + ) => Effect.Effect< + Effect.Success< + ReturnType + >, + Failure + >; + } +>()("t3/mcp/ThreadDiscussionMcpService") {} + +const make = Effect.gen(function* () { + const discussions = yield* ThreadDiscussionService.ThreadDiscussionService; + const engine = yield* Orchestrator.OrchestratorV2; + + // MCP tool declarations enforce write permissions; this resolves the discussion target. + const requireThread = Effect.fn("ThreadDiscussionMcpService.requireThread")(function* ( + scope: McpInvocationContext.McpInvocationScope, + requested: ThreadId | undefined, + ) { + yield* McpInvocationContext.requireMcpCapability("pull-requests").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + ); + const threadId = requested ?? scope.thread?.threadId; + if (!threadId) + return yield* new DiscussionOperationError({ + message: "Pass threadId: this MCP client is not running inside a T3 thread.", + }); + const read = (id: ThreadId) => + engine + .getThreadShell(id) + .pipe( + Effect.mapError( + () => new DiscussionOperationError({ message: "Could not read the thread." }), + ), + ); + const thread = yield* read(threadId); + if (!thread || thread.deletedAt !== null) + return yield* new DiscussionOperationError({ message: `Thread ${threadId} was not found.` }); + return threadId; + }); + + return ThreadDiscussionMcpService.of({ + link: (scope, input) => + requireThread(scope, input.threadId).pipe( + Effect.flatMap((threadId) => discussions.link({ ...input, threadId })), + ), + unlink: (scope, input) => + requireThread(scope, input.threadId).pipe( + Effect.flatMap((threadId) => discussions.unlink({ ...input, threadId })), + ), + list: (scope, input) => + requireThread(scope, input.threadId).pipe(Effect.flatMap(discussions.list)), + }); +}); + +export const layer = Layer.effect(ThreadDiscussionMcpService, make); diff --git a/apps/server/src/mcp/toolkits/discussions/handlers.ts b/apps/server/src/mcp/toolkits/discussions/handlers.ts new file mode 100644 index 0000000000000..5c591a4dceec1 --- /dev/null +++ b/apps/server/src/mcp/toolkits/discussions/handlers.ts @@ -0,0 +1,32 @@ +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as Effect from "effect/Effect"; +import * as ThreadDiscussionMcpService from "../../ThreadDiscussionMcpService.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { DiscussionsToolkit } from "./tools.ts"; + +const make = Effect.gen(function* () { + const service = yield* ThreadDiscussionMcpService.ThreadDiscussionMcpService; + return { + link_discussion: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => service.link(scope, input)), + ), + ), + unlink_discussion: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => service.unlink(scope, input)), + ), + ), + list_thread_discussions: McpToolAccess.reads((input) => + McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => service.list(scope, input)), + ), + ), + } satisfies McpToolAccess.Handlers; +}); + +export const layer = McpToolAccess.toLayer(DiscussionsToolkit, make); diff --git a/apps/server/src/mcp/toolkits/discussions/tools.ts b/apps/server/src/mcp/toolkits/discussions/tools.ts new file mode 100644 index 0000000000000..7632ea46054c5 --- /dev/null +++ b/apps/server/src/mcp/toolkits/discussions/tools.ts @@ -0,0 +1,101 @@ +import { + DiscussionOperationError, + McpCapabilityUnavailableError, + OrchestratorMcpFailure, + PositiveInt, + ThreadDiscussionLink, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/ai/Tool"; +import * as Toolkit from "effect/ai/Toolkit"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as ThreadDiscussionMcpService from "../../ThreadDiscussionMcpService.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + ThreadDiscussionMcpService.ThreadDiscussionMcpService, + ThreadManagement.ThreadManagementService, +]; +const target = Schema.Struct({ + threadId: Schema.optional( + ThreadId.annotate({ description: "Thread to act on. Omit for this thread." }), + ), + url: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "GitHub discussion URL, for example https://github.com/owner/repo/discussions/123.", + }), + ), + repository: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "owner/repo, required with number when url is omitted.", + }), + ), + number: Schema.optional(PositiveInt), + host: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "Only github.com is supported. Defaults to github.com.", + }), + ), +}); +const identity = { + host: Schema.Literal("github.com"), + repository: Schema.String, + number: PositiveInt, +}; +const failure = Schema.Union([ + DiscussionOperationError, + McpCapabilityUnavailableError, + OrchestratorMcpFailure, +]); + +const LinkDiscussionTool = Tool.make("link_discussion", { + description: + "Link a GitHub discussion you create or work on to this thread, so the user can open it or choose it when snoozing until a reply. Call after creating or commenting on a discussion. Pass its URL, or repository plus number. Repeated links succeed with alreadyLinked=true. Linking does not start monitoring, wake an agent, or settle the thread.", + parameters: target, + success: Schema.Struct({ ...identity, url: Schema.String, alreadyLinked: Schema.Boolean }), + failure, + dependencies, +}) + .annotate(Tool.Title, "Link discussion to thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const UnlinkDiscussionTool = Tool.make("unlink_discussion", { + description: + "Remove a discussion link from this thread. Pass the URL, or repository plus number. An absent link succeeds with wasLinked=false. This does not delete anything on GitHub.", + parameters: target, + success: Schema.Struct({ ...identity, wasLinked: Schema.Boolean }), + failure, + dependencies, +}) + .annotate(Tool.Title, "Unlink discussion from thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const ListThreadDiscussionsTool = Tool.make("list_thread_discussions", { + description: + "List saved GitHub discussion links and their known titles. Omit threadId for this thread. Before finishing discussion work, link any discussion you created or commented on that is missing.", + parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), + success: Schema.Struct({ discussions: Schema.Array(ThreadDiscussionLink) }), + failure, + dependencies, +}) + .annotate(Tool.Title, "List thread discussions") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const DiscussionsToolkit = Toolkit.make( + LinkDiscussionTool, + UnlinkDiscussionTool, + ListThreadDiscussionsTool, +); diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index f54065c0824ef..225a59f39a7a9 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -301,6 +301,22 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { } let command: OrchestrationV2Command; switch (input.action) { + case "snooze_until_github_reply": + case "snooze_until_changes_requested": + if (input.conversationUrl === undefined) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: `${input.action} requires conversationUrl.`, + }); + command = { + ...common, + type: "thread.github-reply.snooze", + url: input.conversationUrl, + ...(input.action === "snooze_until_changes_requested" + ? { wakeCondition: "changes-requested" as const } + : {}), + }; + break; case "snooze": if (input.snoozedUntil === undefined) { return yield* new OrchestratorMcpFailure({ diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index b5fb7d6be3a68..7894a58b79c75 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -12,6 +12,7 @@ import { RuntimeRequestId, ProviderUserInputAnswers, IsoDateTime, + GitHubConversationUrl, OrchestratorMcpFailure, OrchestrationV2DispatchCommandResult, ThreadId, @@ -30,13 +31,15 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: - "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.", + "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. snooze_until_github_reply requires conversationUrl, a github.com PR or discussion URL. Use it only when asked to wait for feedback. New comments by other accounts return the thread to the inbox without running an agent. PR issue comments are flat; a discussion comment anchor watches its replies. Open, merged and closed PRs are supported for reply snooze. snooze_until_changes_requested requires conversationUrl for a linked open github.com PR and waits only for a new submitted CHANGES_REQUESTED review by another account, including reviews with no text. It has no deadline and does not run an agent. Existing thread lifecycle rules apply; this does not schedule a future action. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), action: Schema.Literals([ "pin", "unpin", "snooze", + "snooze_until_github_reply", + "snooze_until_changes_requested", "unsnooze", "settle", "unsettle", @@ -45,6 +48,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { "mark_unread", ]), snoozedUntil: Schema.optional(IsoDateTime), + conversationUrl: Schema.optional(GitHubConversationUrl), }), success: Schema.Union([ OrchestrationV2DispatchCommandResult, diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 588d11d9c7152..90adcb29a652e 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -1,3 +1,4 @@ +import * as ThreadDiscussionService from "../../../orchestration-v2/ThreadDiscussionService.ts"; import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../../../orchestration-v2/ProjectionStore.ts"; import * as DeviceService from "../../../device/DeviceService.ts"; @@ -29,6 +30,7 @@ import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts"; const layerStubServices = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({}), + Layer.mock(ThreadDiscussionService.ThreadDiscussionService)({}), Layer.mock(ProjectionStore.ProjectionStoreV2)({}), Layer.mock(DeviceService.DeviceService)({}), Layer.mock(ThreadManagementService.ThreadManagementService)({}), @@ -127,6 +129,9 @@ it.effect("production mcp layer lists worktree tools over http", () => // than replacing them. expect(toolNames).toContain("preview_status"); expect(toolNames).toContain("delegate_task"); + expect(toolNames).toContain("link_discussion"); + expect(toolNames).toContain("list_thread_discussions"); + expect(toolNames).toContain("unlink_discussion"); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index 4f2ea3e2f3d61..44b52daf9ce11 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -1,3 +1,4 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -132,6 +133,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ); const layerLive = RuntimeLayer.layer.pipe( + Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})), Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(SqlitePersistence.layerMemory), Layer.provide(layerCheckpointStore), diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index fe4340474bcb8..8fcaca0ae7b94 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -1,3 +1,6 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -310,7 +313,17 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con }).pipe( Effect.provide( ScheduledTaskService.layer.pipe( - Layer.provide(ThreadManagementService.layer), + Layer.provide( + ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), + ), + ), Layer.provide( Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({ ensureTranscript: () => diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index 1c74ee18d7a92..2e90dd8b7962b 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -1,3 +1,4 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -112,6 +113,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ); const layerLive = RuntimeLayer.layer.pipe( + Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})), Layer.provide(ProviderTurnStartServiceTestkit.layer), Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(SqlitePersistence.layerMemory), diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index ed2ae984501e6..48239658af403 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -1,3 +1,4 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -102,7 +103,10 @@ const layerTestProviderInstanceRegistry = Layer.succeed( }, ); -const layerTest = Layer.mergeAll(RuntimeLayer.layer, RuntimeLayer.layerEventSink).pipe( +const layerTest = Layer.mergeAll( + RuntimeLayer.layer.pipe(Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({}))), + RuntimeLayer.layerEventSink, +).pipe( Layer.provideMerge(RuntimeLayer.layerProjectService), Layer.provide( Layer.mock(WorkspacePaths.WorkspacePaths)({ diff --git a/apps/server/src/orchestration-v2/GitHubReplySnoozeWorker.ts b/apps/server/src/orchestration-v2/GitHubReplySnoozeWorker.ts new file mode 100644 index 0000000000000..84601a580e6f5 --- /dev/null +++ b/apps/server/src/orchestration-v2/GitHubReplySnoozeWorker.ts @@ -0,0 +1,131 @@ +import { + CommandId, + type GitHubReplySnooze, + type OrchestrationV2AppThread, + type GitHubReplyNotice, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; + +export class GitHubReplySnoozeWorker extends Context.Service< + GitHubReplySnoozeWorker, + { + readonly sweep: Effect.Effect; + } +>()("t3/orchestration-v2/GitHubReplySnoozeWorker") {} + +const make = Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const projects = yield* ProjectStore.ProjectStoreV2; + const threads = yield* ThreadManagement.ThreadManagementService; + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const sweep = Effect.gen(function* () { + const now = yield* DateTime.now; + const candidates = yield* projections.getGitHubReplySnoozes(); + yield* Effect.forEach( + candidates, + Effect.fnUntraced( + function* (thread: OrchestrationV2AppThread) { + const watch = thread.githubReplySnooze; + if (!watch || Date.parse(watch.nextCheckAt) > DateTime.toEpochMillis(now)) return; + const project = yield* projects.get(thread.projectId); + const result = yield* ( + Option.isNone(project) + ? Effect.fail(new GitHubReplyReader.GitHubReplyReadError({ reason: "unavailable" })) + : reader.read({ cwd: project.value.workspaceRoot, watch }) + ).pipe(Effect.result); + let next: GitHubReplySnooze | null; + let notice: GitHubReplyNotice | undefined; + if (result._tag === "Success") { + next = { + ...watch, + viewer: result.success.viewer, + ...(result.success.discussionCommentId === undefined + ? {} + : { discussionCommentId: result.success.discussionCommentId }), + status: "watching", + failures: 0, + nextCheckAt: DateTime.formatIso(DateTime.add(now, { minutes: 2 })), + }; + if (result.success.reply) { + next = null; + notice = { + type: "reply", + receivedAt: DateTime.formatIso(now), + ...result.success.reply, + }; + } + } else if (result.failure.reason === "rate-limited") { + next = { + ...watch, + status: "rate-limited", + nextCheckAt: DateTime.formatIso( + DateTime.makeUnsafe( + Math.max(result.failure.retryAt ?? 0, DateTime.toEpochMillis(now) + 120_000), + ), + ), + }; + } else { + const failures = watch.failures + 1; + if (failures >= 3 || result.failure.reason !== "unavailable") { + next = null; + notice = { + type: "error", + receivedAt: DateTime.formatIso(now), + url: watch.url, + text: result.failure.message, + }; + } else { + next = { + ...watch, + failures, + status: "retrying", + nextCheckAt: DateTime.formatIso(DateTime.add(now, { minutes: 2 ** failures })), + }; + } + } + // The orchestrator compares requestId under its thread lock. Late results cannot wake a replacement snooze. + yield* threads.dispatch({ + type: "thread.github-reply.sync", + threadId: thread.id, + commandId: CommandId.make(`github-reply:${watch.requestId}:${watch.nextCheckAt}`), + requestId: watch.requestId, + watch: next, + ...(notice === undefined ? {} : { notice }), + }); + }, + Effect.catchCause((cause) => + Effect.logWarning("github-reply-snooze.check-failed", { cause }), + ), + ), + { concurrency: 2, discard: true }, + ); + }); + return GitHubReplySnoozeWorker.of({ sweep }); +}); + +export const layer = Layer.effect(GitHubReplySnoozeWorker, make); +export const layerScheduled = Layer.effectDiscard( + Effect.gen(function* () { + const worker = yield* GitHubReplySnoozeWorker; + const scheduler = yield* Scheduler.Scheduler; + let nextSweepAt = 0; + yield* scheduler.register( + "github-reply-snooze", + Effect.gen(function* () { + const now = DateTime.toEpochMillis(yield* DateTime.now); + if (now < nextSweepAt) return; + nextSweepAt = now + 30_000; + yield* worker.sweep; + }), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index 15d29d30f8acd..fea620a9e7f87 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -1,3 +1,4 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -111,6 +112,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ); const layerLive = RuntimeLayer.layer.pipe( + Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})), Layer.provide(ProviderTurnStartServiceTestkit.layer), Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(SqlitePersistence.layerMemory), diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 55d93ded6e6e7..3f07886278340 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -17,6 +17,7 @@ * stand-in one) and a turn cut off by a killed server. Each step waits up to * `OPENCODE2_STEP_WAIT` seconds (120 by default). */ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -204,6 +205,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ), ); const layerOrchestration = RuntimeLayer.layer.pipe( + Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})), Layer.provide(ProviderTurnStartServiceTestkit.layer), Layer.provide(layerMcpRegistry), Layer.provide(SqlitePersistence.layerMemory), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c85f11c568350..cb74fa7e484cc 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,3 +1,4 @@ +import { parseGitHubConversationUrl } from "@t3tools/contracts"; import type { OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, @@ -11,6 +12,7 @@ import { } from "@t3tools/shared/orchestrationV2ThreadError"; import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; import { + isLinkedOpenGitHubPullRequest, normalizeThreadPullRequestKey, visibleThreadPullRequests, threadPullRequestKeysEqual, @@ -89,6 +91,7 @@ import { notificationTurnItem } from "@t3tools/provider-core/server/notification import { isRestartNoteSource } from "./RestartBackgroundNote.ts"; import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; import { EventSinkV2 } from "./EventSink.ts"; +import * as EventStore from "./EventStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import { IdAllocatorV2 } from "@t3tools/provider-core/server/IdAllocator"; @@ -285,6 +288,11 @@ export interface OrchestratorV2Shape { ) => Effect.Effect; readonly dispatch: ( command: OrchestrationV2ServerCommand, + /** Baseline setup uses a server-owned cursor; clients cannot supply this precondition. */ + preconditions?: { + readonly githubReplySnoozeAfterSequence: number; + readonly githubReplySnoozeRecoveryRequestId: CommandId | null; + }, ) => Effect.Effect; readonly searchThreadStream: ( input: OrchestrationV2SearchThreadInput, @@ -433,6 +441,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.settle": case "thread.auto-settle": case "thread.unsettle": + case "thread.github-reply.snooze": + case "thread.github-reply.sync": case "thread.snooze": case "thread.unsnooze": case "thread.auto-settle.set": @@ -443,6 +453,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.visit": case "thread.mark-unread": case "thread.metadata.update": + case "thread.discussion.link": + case "thread.discussion.unlink": case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": @@ -799,6 +811,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const commandPolicy = yield* CommandPolicyV2; const contextHandoffService = yield* ContextHandoffServiceV2; const eventSink = yield* EventSinkV2; + const eventStore = yield* EventStore.EventStoreV2; const commandReceipts = yield* CommandReceiptStoreV2; const idAllocator = yield* IdAllocatorV2; const projects = yield* ProjectStore.ProjectStoreV2; @@ -2335,6 +2348,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio thread.archivedAt !== null || thread.settledOverride === "settled" || thread.settledAt !== null || + thread.githubReplySnooze != null || isProviderNativeSubagentThread(thread); if (link?.watch?.startedAt !== command.startedAt || (command.wake && inactive)) { return yield* new OrchestratorDispatchError({ @@ -2373,6 +2387,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.unarchive" | "thread.settle" | "thread.unsettle" + | "thread.github-reply.snooze" + | "thread.github-reply.sync" | "thread.snooze" | "thread.unsnooze" | "thread.auto-settle.set" @@ -2382,6 +2398,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.active.reorder" | "thread.mark-unread" | "thread.metadata.update" + | "thread.discussion.link" + | "thread.discussion.unlink" | "thread.pull-request.link" | "thread.pull-request.unlink" | "thread.pull-request-link.sync" @@ -2429,12 +2447,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.watching && (thread.settledOverride === "settled" || thread.settledAt !== null || - thread.archivedAt !== null) + thread.archivedAt !== null || + thread.githubReplySnooze != null) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Thread ${command.threadId} is settled or archived and cannot watch pull requests.`, + cause: `Thread ${command.threadId} is settled, archived, or snoozed for a GitHub reply and cannot watch pull requests.`, }); } // A subagent or delegated task reports to its parent thread, which owns the pull request. @@ -2511,6 +2530,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( (command.type === "thread.settle" || command.type === "thread.unsettle" || + command.type === "thread.github-reply.snooze" || command.type === "thread.snooze" || command.type === "thread.unsnooze" || command.type === "thread.auto-settle.set" || @@ -2712,6 +2732,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio !Number.isFinite(resetMs) || thread.archivedAt !== null || thread.settledOverride === "settled" || + (command.automaticLimitRecovery === true && + (thread.githubReplySnooze != null || + thread.githubReplyAutoResumeBlockedRunId === command.limitRecovery.runId)) || run?.id !== command.limitRecovery.runId || failure?.class !== "usage_limit" || failure.resetAt !== command.limitRecovery.resetAt || @@ -2725,15 +2748,49 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } } + if ( + command.type === "thread.github-reply.snooze" && + command.resumeFrom && + (command.resumeFrom.url !== command.url || + (command.wakeCondition !== undefined && + command.wakeCondition !== (command.resumeFrom.wakeCondition ?? "any-reply"))) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The restored GitHub snooze must use the same conversation and wake condition.", + }); + } + if ( + command.type === "thread.github-reply.snooze" && + (command.wakeCondition ?? command.resumeFrom?.wakeCondition) === "changes-requested" && + !isLinkedOpenGitHubPullRequest(thread.pullRequests ?? [], command.url) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Changes-requested snooze requires a linked open github.com pull request.", + }); + } let snoozedUntil: DateTime.Utc | null = null; - if (command.type === "thread.snooze") { + let githubReplyAutoResumeBlockedRunId = thread.githubReplyAutoResumeBlockedRunId ?? null; + if ( + command.type === "thread.github-reply.sync" && + (thread.githubReplySnooze?.requestId !== command.requestId || + thread.archivedAt !== null || + thread.settledOverride === "settled") + ) + return; + if (command.type === "thread.snooze" || command.type === "thread.github-reply.snooze") { const projection = yield* loadProjectionForCommand(command, ["runs", "runtimeRequests"], { turnItemTypes: [], }); - const parsedSnoozedUntil = DateTime.make(command.snoozedUntil); + const parsedSnoozedUntil = + command.type === "thread.snooze" ? DateTime.make(command.snoozedUntil) : Option.some(now); if ( - Option.isNone(parsedSnoozedUntil) || - DateTime.toEpochMillis(parsedSnoozedUntil.value) <= DateTime.toEpochMillis(now) + command.type === "thread.snooze" && + (Option.isNone(parsedSnoozedUntil) || + DateTime.toEpochMillis(parsedSnoozedUntil.value) <= DateTime.toEpochMillis(now)) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -2755,7 +2812,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} has a queued run and cannot be snoozed.`, }); } - snoozedUntil = parsedSnoozedUntil.value; + snoozedUntil = Option.getOrNull(parsedSnoozedUntil); + if (command.type === "thread.github-reply.snooze") { + // Keep this run suppressed after wake until an explicit send or recovery request. + githubReplyAutoResumeBlockedRunId = latestExecutedRun(projection.runs)?.id ?? null; + } } let markUnreadVisitedAt: DateTime.Utc | null = null; if (command.type === "thread.mark-unread") { @@ -2770,6 +2831,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } markUnreadVisitedAt = DateTime.subtract(latestRunCompletedAt, { milliseconds: 1 }); } + if (command.type === "thread.discussion.link") { + const target = command.discussion; + const parsed = parseGitHubConversationUrl(target.url); + if ( + parsed?.kind !== "discussion" || + parsed.number !== target.number || + `${parsed.owner}/${parsed.repository}`.toLowerCase() !== target.repository.toLowerCase() + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Discussion URL does not match its repository and number.", + }); + } + } + const updatedThread: OrchestrationV2AppThread = (() => { switch (command.type) { case "thread.archive": @@ -2777,6 +2854,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { ...thread, archivedAt: now, + githubReplySnooze: null, + githubReplyNotice: null, + snoozedAt: thread.githubReplySnooze ? null : thread.snoozedAt, titleRegeneration: null, pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), updatedAt: now, @@ -2792,6 +2872,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { ...thread, settledOverride: "settled", + githubReplySnooze: null, + snoozedAt: thread.githubReplySnooze ? null : thread.snoozedAt, settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? now), pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), unsettledAt: null, @@ -2811,6 +2893,50 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: alreadyPinnedActive ? thread.updatedAt : now, }; } + case "thread.github-reply.snooze": + return { + ...thread, + githubReplySnooze: { + ...command.resumeFrom, + requestId: command.commandId, + url: command.url, + wakeCondition: command.wakeCondition ?? command.resumeFrom?.wakeCondition, + startedAt: command.resumeFrom?.startedAt ?? DateTime.formatIso(now), + nextCheckAt: DateTime.formatIso(now), + status: "pending", + failures: 0, + }, + githubReplyNotice: null, + snoozedUntil: null, + snoozedAt: now, + settledOverride: "active", + settledAt: null, + limitRecovery: null, + githubReplyAutoResumeBlockedRunId, + pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), + updatedAt: now, + }; + case "thread.github-reply.sync": + return { + ...thread, + githubReplySnooze: command.watch, + ...(command.notice === undefined + ? {} + : { + githubReplyNotice: { + ...command.notice, + receivedAt: DateTime.formatIso(now), + wakeCondition: thread.githubReplySnooze?.wakeCondition, + conversationUrl: + thread.githubReplySnooze?.url ?? command.notice.conversationUrl, + }, + snoozedAt: null, + settledOverride: "active" as const, + settledAt: null, + unsettledAt: now, + updatedAt: now, + }), + }; case "thread.snooze": { const sameWakeTime = thread.snoozedUntil != null && @@ -2820,16 +2946,23 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { ...thread, snoozedUntil, + githubReplySnooze: null, + githubReplyNotice: null, limitRecovery: thread.limitRecovery ? { ...thread.limitRecovery, snooze: false } : null, snoozedAt: existingSnoozedAt ?? now, updatedAt: existingSnoozedAt === null ? now : thread.updatedAt, }; } case "thread.unsnooze": { - const alreadyAwake = thread.snoozedUntil == null; + const alreadyAwake = + thread.snoozedUntil == null && + thread.githubReplySnooze == null && + thread.githubReplyNotice == null; return { ...thread, snoozedUntil: null, + githubReplySnooze: null, + githubReplyNotice: null, snoozedAt: null, updatedAt: alreadyAwake ? thread.updatedAt : now, }; @@ -2861,6 +2994,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio thread.settledOverride === "settled" ? "active" : thread.settledOverride, settledAt: thread.settledOverride === "settled" ? null : thread.settledAt, snoozedUntil: null, + githubReplySnooze: null, snoozedAt: null, updatedAt: alreadyPinned && !promotes ? thread.updatedAt : now, }; @@ -2915,15 +3049,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio snooze: command.limitRecovery.snooze ?? previousRecovery?.snooze ?? false, requestId: command.commandId, }; + const explicitlyResuming = + command.automaticLimitRecovery !== true && command.limitRecovery?.autoResume === true; return { ...thread, ...(command.title === undefined ? {} : { title: command.title }), ...(command.limitRecovery === undefined ? {} : { limitRecovery }), + // Explicit recovery replaces a passive GitHub wait; automatic re-arms stay blocked. + ...(explicitlyResuming + ? { + githubReplyAutoResumeBlockedRunId: null, + githubReplySnooze: null, + ...(thread.githubReplySnooze != null ? { snoozedAt: null } : {}), + } + : {}), ...(command.limitRecovery !== undefined && limitRecovery?.snooze === true && Date.parse(limitRecovery.resetAt) > DateTime.toEpochMillis(now) ? { snoozedUntil: DateTime.makeUnsafe(limitRecovery.resetAt), + githubReplySnooze: null, // Recovery changes acknowledge the same stopped run; keep its // metadata timestamp from appearing as a fresh failure wake. snoozedAt: now, @@ -2974,6 +3119,37 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: now, }; } + case "thread.discussion.link": + case "thread.discussion.unlink": { + const target = command.discussion; + const links = thread.discussions ?? []; + const matches = (link: typeof target) => + link.host === target.host && + link.repository.toLowerCase() === target.repository.toLowerCase() && + link.number === target.number; + const exists = links.some(matches); + if (command.type === "thread.discussion.unlink") { + return exists + ? { ...thread, discussions: links.filter((link) => !matches(link)), updatedAt: now } + : thread; + } + if (exists) return thread; + return { + ...thread, + discussions: [ + ...links, + { + ...target, + repository: target.repository.toLowerCase(), + url: `https://github.com/${target.repository.toLowerCase()}/discussions/${target.number}`, + title: command.title ?? null, + source: command.source, + linkedAt: DateTime.formatIso(now), + }, + ], + updatedAt: now, + }; + } case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": { @@ -3189,8 +3365,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return "thread.settled" as const; case "thread.unsettle": return "thread.unsettled" as const; + case "thread.github-reply.snooze": case "thread.snooze": return "thread.snoozed" as const; + case "thread.github-reply.sync": + return command.notice + ? ("thread.unsnoozed" as const) + : ("thread.metadata-updated" as const); case "thread.unsnooze": return "thread.unsnoozed" as const; case "thread.auto-settle.set": @@ -3206,6 +3387,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.mark-unread": return "thread.marked-unread" as const; case "thread.metadata.update": + case "thread.discussion.link": + case "thread.discussion.unlink": case "thread.title.regeneration.complete": return "thread.metadata-updated" as const; case "thread.pull-request.link": @@ -4502,6 +4685,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || projection.thread.settledOverride === "settled" || + projection.thread.githubReplySnooze != null || + projection.thread.githubReplyAutoResumeBlockedRunId === run?.id || projection.thread.providerInstanceId !== run.providerInstanceId || projection.runtimeRequests.some((request) => request.status === "pending") || (projection.thread.snoozedUntil != null && @@ -4537,6 +4722,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio isRestartNoteSource(source, projection.providerTurns) || projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || + projection.thread.githubReplySnooze != null || + projection.thread.githubReplyAutoResumeBlockedRunId === source.id || projection.thread.providerInstanceId !== source.providerInstanceId || // Held queued runs never started; they wait behind the continuation. projection.runs.some( @@ -4583,11 +4770,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); projection = yield* getProjectionWithPendingEvents(command.threadId, events); } - if (projection.thread.snoozedUntil != null) { + const clearReplyResumeBlock = + command.createdBy === "user" && projection.thread.githubReplyAutoResumeBlockedRunId != null; + if ( + projection.thread.snoozedUntil != null || + projection.thread.githubReplySnooze != null || + clearReplyResumeBlock + ) { const now = yield* DateTime.now; const thread: OrchestrationV2AppThread = { ...projection.thread, snoozedUntil: null, + githubReplySnooze: null, + ...(clearReplyResumeBlock ? { githubReplyAutoResumeBlockedRunId: null } : {}), snoozedAt: null, updatedAt: now, }; @@ -10122,6 +10317,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchOnce = Effect.fn("orchestrationV2.dispatch.once")(function* ( command: OrchestrationV2ServerCommand, + preconditions?: Parameters[1], ): Effect.fn.Return< { readonly events: ReadonlyArray; @@ -10139,6 +10335,56 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio "orchestration_v2.thread_id": commandThreadId(command), }); + if (command.type === "thread.github-reply.snooze" && preconditions !== undefined) { + // Lifecycle events catch even a no-op Wake or changes within the same clock tick. + // Provider progress and completion can still finish during the baseline fetch. + const superseded = yield* eventStore + .read({ + threadId: command.threadId, + afterSequence: preconditions.githubReplySnoozeAfterSequence, + throughSequence: yield* eventSink + .latestSequence({ threadId: command.threadId }) + .pipe(mapDispatchError(command)), + }) + .pipe( + Stream.filter(({ event, commandId: eventCommandId }) => { + switch (event.type) { + case "thread.snoozed": + case "thread.unsnoozed": + case "thread.settled": + case "thread.unsettled": + case "thread.archived": + case "thread.unarchived": + case "thread.deleted": + case "thread.pinned": + case "run.created": + return true; + case "thread.metadata-updated": + // Recovery writes carry their command ID; a rename keeps the older ID. + return ( + (event.payload.limitRecovery?.requestId ?? null) !== + preconditions.githubReplySnoozeRecoveryRequestId || + (eventCommandId !== null && + event.payload.limitRecovery?.requestId === eventCommandId) + ); + case "message.updated": + return event.payload.role === "user"; + default: + return false; + } + }), + Stream.runHead, + mapDispatchError(command), + ); + if (Option.isSome(superseded)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The thread changed while the GitHub reply snooze was being prepared.", + }); + } + } + const events = yield* Ref.make>([]); const effects = yield* Ref.make>([]); let cancelUnsettledEffects: @@ -10219,6 +10465,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.unarchive": case "thread.settle": case "thread.unsettle": + case "thread.github-reply.snooze": + case "thread.github-reply.sync": case "thread.snooze": case "thread.unsnooze": case "thread.auto-settle.set": @@ -10228,6 +10476,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.active.reorder": case "thread.mark-unread": case "thread.metadata.update": + case "thread.discussion.link": + case "thread.discussion.unlink": case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": @@ -10420,6 +10670,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchWithReceiptEffect = Effect.fn("orchestrationV2.dispatch.withReceipt")(function* ( command: OrchestrationV2ServerCommand, + preconditions?: Parameters[1], ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -10493,7 +10744,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell); } - const plan = yield* dispatchOnce(command).pipe( + const plan = yield* dispatchOnce(command, preconditions).pipe( Effect.flatMap((planned) => // A settle that finds the provider already ended everything, or a // stop that finds nothing running, has nothing to record. That is @@ -10624,8 +10875,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } satisfies OrchestratorV2DispatchResult; }); - const dispatchWithReceipt = (command: OrchestrationV2ServerCommand) => - threadDispatch.withLock(commandThreadId(command), dispatchWithReceiptEffect(command)); + const dispatchWithReceipt: OrchestratorV2Shape["dispatch"] = (command, preconditions) => + threadDispatch.withLock( + commandThreadId(command), + dispatchWithReceiptEffect(command, preconditions), + ); const handleTerminalRun = (stored: OrchestrationV2StoredEvent) => Effect.gen(function* () { @@ -10955,6 +11209,7 @@ export const layer: Layer.Layer< | ContextHandoffServiceV2 | EffectOutbox.EffectOutboxV2 | EventSinkV2 + | EventStore.EventStoreV2 | IdAllocatorV2 | ProjectStore.ProjectStoreV2 | ProviderAdapterRegistryV2 diff --git a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts index 733f9467f10a9..746f020721c1d 100644 --- a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts +++ b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts @@ -1,3 +1,4 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { ProjectId } from "@t3tools/contracts"; @@ -136,7 +137,10 @@ const layerRuntime = (dbPath: string) => { ), ); return Layer.mergeAll( - RuntimeLayer.layer.pipe(Layer.provide(RuntimeLayer.layerProjectService)), + RuntimeLayer.layer.pipe( + Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})), + Layer.provide(RuntimeLayer.layerProjectService), + ), RuntimeLayer.layerProjectService, ).pipe( Layer.provide( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a6a092ff84b7a..db8c3e6b63b9d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -165,6 +165,8 @@ export type ProjectionLimitRecoveryCandidate = Pick< | "updatedAt" | "limitRecovery" | "snoozedUntil" + | "githubReplySnooze" + | "githubReplyAutoResumeBlockedRunId" >; /** The thread fields pull request sync reads, for a thread with at least one link. */ @@ -194,6 +196,8 @@ export type ProjectionSettlementCandidate = Pick< | "autoSettleDisabledAt" | "snoozedUntil" | "snoozedAt" + | "githubReplySnooze" + | "githubReplyNotice" | "latestRunId" | "latestRunRequestedAt" | "latestRunStartedAt" @@ -388,6 +392,10 @@ export interface ProjectionStoreV2Shape { readonly getThread: ( threadId: ThreadId, ) => Effect.Effect; + readonly getGitHubReplySnoozes: () => Effect.Effect< + ReadonlyArray, + ProjectionStoreV2Error + >; readonly getLimitRecoveryCandidates: (options: { readonly now: DateTime.Utc; readonly autoResume: boolean; @@ -1443,6 +1451,7 @@ export function threadShellFromProjection( branch: projection.thread.branch, worktreePath: projection.thread.worktreePath, pullRequests: threadPullRequestsOf(projection.thread), + discussions: projection.thread.discussions ?? [], ...(projection.thread.linkedPullRequest === undefined ? {} : { linkedPullRequest: projection.thread.linkedPullRequest }), @@ -1507,6 +1516,8 @@ export function threadShellFromProjection( unsettledAt: projection.thread.unsettledAt ?? null, snoozedUntil: projection.thread.snoozedUntil ?? null, snoozedAt: projection.thread.snoozedAt ?? null, + githubReplySnooze: projection.thread.githubReplySnooze ?? null, + githubReplyNotice: projection.thread.githubReplyNotice ?? null, pinnedAt: projection.thread.pinnedAt ?? null, autoSettleDisabledAt: projection.thread.autoSettleDisabledAt ?? null, @@ -1514,6 +1525,7 @@ export function threadShellFromProjection( lastVisitedAt: projection.thread.lastVisitedAt, titleRegeneration: projection.thread.titleRegeneration ?? null, limitRecovery: projection.thread.limitRecovery ?? null, + githubReplyAutoResumeBlockedRunId: projection.thread.githubReplyAutoResumeBlockedRunId ?? null, deletedAt: projection.thread.deletedAt, }; } @@ -1711,6 +1723,7 @@ function shellFromState(input: { branch: input.state.thread.branch, worktreePath: input.state.thread.worktreePath, pullRequests: threadPullRequestsOf(input.state.thread), + discussions: input.state.thread.discussions ?? [], ...(input.state.thread.linkedPullRequest === undefined ? {} : { linkedPullRequest: input.state.thread.linkedPullRequest }), @@ -1762,6 +1775,8 @@ function shellFromState(input: { unsettledAt: input.state.thread.unsettledAt ?? null, snoozedUntil: input.state.thread.snoozedUntil ?? null, snoozedAt: input.state.thread.snoozedAt ?? null, + githubReplySnooze: input.state.thread.githubReplySnooze ?? null, + githubReplyNotice: input.state.thread.githubReplyNotice ?? null, pinnedAt: input.state.thread.pinnedAt ?? null, autoSettleDisabledAt: input.state.thread.autoSettleDisabledAt ?? null, @@ -1769,6 +1784,7 @@ function shellFromState(input: { lastVisitedAt: input.state.thread.lastVisitedAt, titleRegeneration: input.state.thread.titleRegeneration ?? null, limitRecovery: input.state.thread.limitRecovery ?? null, + githubReplyAutoResumeBlockedRunId: input.state.thread.githubReplyAutoResumeBlockedRunId ?? null, deletedAt: input.state.thread.deletedAt, }; } @@ -3436,6 +3452,20 @@ export const layer: Layer.Layer = ), ); + const getGitHubReplySnoozes = Effect.fn("ProjectionStore.getGitHubReplySnoozes")( + function* () { + const rows = yield* sql<{ readonly payload_json: string }>` + SELECT payload_json FROM orchestration_v2_projection_threads + WHERE deleted_at IS NULL + AND json_extract(payload_json, '$.archivedAt') IS NULL + AND json_extract(payload_json, '$.settledOverride') IS NOT 'settled' + AND json_extract(payload_json, '$.githubReplySnooze') IS NOT NULL + `; + return yield* Effect.forEach(rows, (row) => decodeThreadPayload(row.payload_json)); + }, + Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), + ); + const getLimitRecoveryCandidates = Effect.fn("ProjectionStore.getLimitRecoveryCandidates")( function* (options: Parameters[0]) { // Indexed latest-run and root-error lookups avoid reading run histories, @@ -3537,7 +3567,9 @@ export const layer: Layer.Layer = settledOverride: thread.settledOverride, pendingRuntimeRequest: null, limitRecovery: thread.limitRecovery ?? null, + githubReplyAutoResumeBlockedRunId: thread.githubReplyAutoResumeBlockedRunId ?? null, snoozedUntil: thread.snoozedUntil ?? null, + githubReplySnooze: thread.githubReplySnooze ?? null, }); } return candidates; @@ -5494,6 +5526,8 @@ export const layer: Layer.Layer = autoSettleDisabledAt: thread.autoSettleDisabledAt ?? null, snoozedUntil: thread.snoozedUntil ?? null, snoozedAt: thread.snoozedAt ?? null, + githubReplySnooze: thread.githubReplySnooze ?? null, + githubReplyNotice: thread.githubReplyNotice ?? null, status, latestRunId, latestRunRequestedAt: @@ -5923,6 +5957,7 @@ export const layer: Layer.Layer = getRuntimeRequest, getPlan, getProviderControlContext, + getGitHubReplySnoozes, getLimitRecoveryCandidates, getRecoveryThreadIds, getUnreadableThreadIds, @@ -6071,6 +6106,20 @@ export const layerMemory: Layer.Layer = Layer.effect( })), ), ), + getGitHubReplySnoozes: () => + Ref.get(replayState).pipe( + Effect.map((state) => + [...state.projections.values()] + .map(({ thread }) => thread) + .filter( + (thread) => + thread.deletedAt === null && + thread.archivedAt === null && + thread.settledOverride !== "settled" && + thread.githubReplySnooze != null, + ), + ), + ), getLimitRecoveryCandidates: (options) => Ref.get(replayState).pipe( Effect.map((state) => diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 42b3943e71d0c..b83eaf16ec787 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -211,6 +211,7 @@ it.effect( ProjectionStore.ProjectionStoreV2, ProjectionStore.ProjectionStoreV2.of({ apply: () => Effect.void, + getGitHubReplySnoozes: () => Effect.die("unused"), getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index dcbf4aa637284..46546fc6a4d44 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + CommandId, MessageId, ProjectId, ProviderDriverKind, @@ -32,6 +33,14 @@ const driver = ProviderDriverKind.make("codex"); const providerThreadId = ProviderThreadId.make("provider-thread:restart"); const sessionId = ProviderSessionId.make("session:restart"); const attemptId = RunAttemptId.make("attempt:restart"); +const replySnooze = { + requestId: CommandId.make("reply-snooze:restart"), + url: "https://github.com/team/repo/pull/1", + startedAt: "2026-10-01T00:00:00Z", + nextCheckAt: "2026-10-01T00:00:00Z", + status: "pending" as const, + failures: 0, +}; // "No project" threads belong to the environment's Scratch project. const scratchProjectId = ProjectId.make("project:scratch"); @@ -139,6 +148,67 @@ it("continues a live turn whose session the adapter never marked running", () => assert.isUndefined(restartContinuationRun(withSessionStatus(status)), status); }); +it.each(["watching", "reply-received", "prepared-continuation"] as const)( + "does not select GitHub reply snoozed work for restart: %s", + (scenario) => { + const base = makeProjection(); + const prepared = scenario === "prepared-continuation"; + const projection = { + ...base, + thread: { + ...base.thread, + githubReplySnooze: scenario === "watching" ? replySnooze : null, + githubReplyAutoResumeBlockedRunId: scenario === "watching" ? null : runId, + }, + runs: prepared + ? [ + { + ...base.runs[0]!, + id: RunId.make("run:prepared-continuation"), + status: "starting" as const, + restartContinuationOfRunId: runId, + }, + ] + : base.runs, + }; + assert.isUndefined(restartContinuationRun(projection)); + }, +); + +it.effect.each(["watching", "reply-received"] as const)( + "does not deliver a pending restart after GitHub reply snooze: %s", + (scenario) => + Effect.gen(function* () { + const base = makeProjection(); + const projection = { + ...base, + thread: { + ...base.thread, + githubReplySnooze: scenario === "watching" ? replySnooze : null, + githubReplyAutoResumeBlockedRunId: scenario === "watching" ? null : runId, + }, + runs: [{ ...base.runs[0]!, status: "cancelled" as const }], + }; + let deliveries = 0; + yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + Effect.provide( + Layer.merge( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, + dispatch: () => { + deliveries++; + return Effect.succeed({} as never); + }, + }), + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + ), + ), + ); + assert.equal(deliveries, 0); + }), +); + it("recovers an admitted continuation after another crash before provider start", () => { const projection = makeProjection(); const starting = { diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 013e7a07fbc45..14efc98250b4d 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -31,7 +31,12 @@ export function restartContinuationRun( "thread" | "runs" | "providerThreads" | "providerSessions" | "providerTurns" >, ): OrchestrationV2Run | undefined { - if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) return; + if ( + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null || + projection.thread.githubReplySnooze != null + ) + return; // Queued runs never started; recovery holds them behind the cut run. const run = projection.runs.reduce( (latest, candidate) => @@ -41,6 +46,12 @@ export function restartContinuationRun( undefined, ); if (!run) return; + if ( + projection.thread.githubReplyAutoResumeBlockedRunId === run.id || + (run.restartContinuationOfRunId !== undefined && + projection.thread.githubReplyAutoResumeBlockedRunId === run.restartContinuationOfRunId) + ) + return; const preparedContinuation = run.status === "starting" && run.restartContinuationOfRunId !== undefined; if (run.status !== "running" && !preparedContinuation) return; @@ -104,7 +115,13 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta .continueThreadsAfterServerUpdate ) return; - if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) return; + if ( + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null || + projection.thread.githubReplySnooze != null || + projection.thread.githubReplyAutoResumeBlockedRunId === input.sourceRunId + ) + return; if (projection.messages.some((message) => message.id === messageId)) return; const source = projection.runs.find((run) => run.id === input.sourceRunId); diff --git a/apps/server/src/orchestration-v2/ThreadDiscussionService.ts b/apps/server/src/orchestration-v2/ThreadDiscussionService.ts new file mode 100644 index 0000000000000..3758686f9a8a1 --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadDiscussionService.ts @@ -0,0 +1,183 @@ +import { + CommandId, + DiscussionOperationError, + parseGitHubConversationUrl, + type ThreadDiscussionLink, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as GitHubDiscussions from "../sourceControl/GitHubDiscussions.ts"; + +export interface DiscussionTargetInput { + readonly threadId: ThreadId; + readonly url?: string | undefined; + readonly host?: string | undefined; + readonly repository?: string | undefined; + readonly number?: number | undefined; +} + +type DiscussionIdentity = Pick; + +export class ThreadDiscussionService extends Context.Service< + ThreadDiscussionService, + { + readonly link: ( + input: DiscussionTargetInput, + ) => Effect.Effect< + DiscussionIdentity & { readonly alreadyLinked: boolean }, + DiscussionOperationError + >; + readonly unlink: ( + input: DiscussionTargetInput, + ) => Effect.Effect< + Omit & { readonly wasLinked: boolean }, + DiscussionOperationError + >; + readonly list: ( + threadId: ThreadId, + ) => Effect.Effect< + { readonly discussions: ReadonlyArray }, + DiscussionOperationError + >; + } +>()("t3/orchestration-v2/ThreadDiscussionService") {} + +const resolveTarget = Effect.fn("ThreadDiscussionService.resolveTarget")(function* ( + input: DiscussionTargetInput, +) { + if (input.host !== undefined && input.host.toLowerCase() !== "github.com") + return yield* new DiscussionOperationError({ + message: "Only github.com discussions are supported.", + }); + if (input.url === undefined && (input.repository === undefined || input.number === undefined)) + return yield* new DiscussionOperationError({ + message: "Pass either url, or both repository and number.", + }); + const url = input.url ?? `https://github.com/${input.repository}/discussions/${input.number}`; + const parsed = parseGitHubConversationUrl(url); + if (parsed?.kind !== "discussion" || !Number.isSafeInteger(parsed.number)) + return yield* new DiscussionOperationError({ + message: "Use a github.com repository discussion URL.", + }); + const repository = `${parsed.owner}/${parsed.repository}`.toLowerCase(); + return { + host: "github.com" as const, + repository, + number: parsed.number, + url: `https://github.com/${repository}/discussions/${parsed.number}`, + }; +}); + +const make = Effect.gen(function* () { + const engine = yield* Orchestrator.OrchestratorV2; + const projects = yield* ProjectStore.ProjectStoreV2; + const reader = yield* GitHubDiscussions.GitHubDiscussions; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const failure = () => + new DiscussionOperationError({ message: "Could not update this thread's discussions." }); + + const requireThread = Effect.fn("ThreadDiscussionService.requireThread")(function* ( + threadId: ThreadId, + ) { + const thread = yield* engine.getThreadShell(threadId).pipe(Effect.mapError(failure)); + if (!thread || thread.deletedAt !== null) + return yield* new DiscussionOperationError({ message: `Thread ${threadId} was not found.` }); + return thread; + }); + + const workspace = Effect.fn("ThreadDiscussionService.workspace")(function* (threadId: ThreadId) { + const thread = yield* requireThread(threadId); + const project = yield* projects.getShell(thread.projectId).pipe(Effect.mapError(failure)); + if (Option.isNone(project)) + return yield* new DiscussionOperationError({ + message: "This thread's project was not found.", + }); + const cwd = + thread.worktreePath !== null && + (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.mapError(failure))) + ? thread.worktreePath + : project.value.workspaceRoot; + return cwd; + }); + + const link = Effect.fn("ThreadDiscussionService.link")(function* (input: DiscussionTargetInput) { + const thread = yield* requireThread(input.threadId); + const target = yield* resolveTarget(input); + if ( + thread.discussions?.some( + (link) => + link.repository.toLowerCase() === target.repository && link.number === target.number, + ) + ) + return { ...target, alreadyLinked: true }; + const discussion = yield* reader.summary({ cwd: yield* workspace(thread.id), url: target.url }); + const current = yield* requireThread(thread.id); + if (current.projectId !== thread.projectId || current.worktreePath !== thread.worktreePath) + return yield* new DiscussionOperationError({ + message: "The thread's workspace changed. Link the discussion again.", + }); + const alreadyLinked = + current.discussions?.some( + (link) => + link.repository.toLowerCase() === target.repository && link.number === target.number, + ) ?? false; + if (!alreadyLinked) { + yield* engine + .dispatch({ + type: "thread.discussion.link", + commandId: CommandId.make( + `server:discussion-link:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`, + ), + threadId: thread.id, + discussion: target, + title: discussion.title, + source: "agent", + }) + .pipe(Effect.mapError(failure)); + } + return { ...target, alreadyLinked }; + }); + + const unlink = Effect.fn("ThreadDiscussionService.unlink")(function* ( + input: DiscussionTargetInput, + ) { + const thread = yield* requireThread(input.threadId); + const target = yield* resolveTarget(input); + const wasLinked = + thread.discussions?.some( + (link) => + link.repository.toLowerCase() === target.repository && link.number === target.number, + ) ?? false; + if (wasLinked) + yield* engine + .dispatch({ + type: "thread.discussion.unlink", + commandId: CommandId.make( + `server:discussion-unlink:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`, + ), + threadId: thread.id, + discussion: target, + }) + .pipe(Effect.mapError(failure)); + return { host: target.host, repository: target.repository, number: target.number, wasLinked }; + }); + + return ThreadDiscussionService.of({ + link, + unlink, + list: (threadId) => + requireThread(threadId).pipe( + Effect.map((thread) => ({ discussions: thread.discussions ?? [] })), + ), + }); +}); + +export const layer = Layer.effect(ThreadDiscussionService, make); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index 56c7aa81693de..4a2bef244c1ac 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -108,6 +108,8 @@ export const layer: Layer.Layer = Layer.succeed( settledOverride: null, settledAt: null, snoozedUntil: null, + githubReplySnooze: null, + githubReplyNotice: null, snoozedAt: null, lastVisitedAt: null, deletedAt: null, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index bfbd078e00c5d..aa9e7a0c9a33e 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1,3 +1,4 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as WorktreeSetupTracker from "../project/WorktreeSetupTracker.ts"; import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; @@ -117,7 +118,16 @@ function makeHarness(options: HarnessOptions = {}) { layerRegistry, { databaseLayer: layerDatabase, runEffectWorker: false }, ); - const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator)); + const layerThreadManagement = ThreadManagement.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), + Layer.provide(layerOrchestrator), + ); const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase)); const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); const createWorktree = vi.fn( diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 94e17bdc567e7..8353123c285d0 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -1,3 +1,6 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import { expect, it } from "@effect/vitest"; import { CommandId, @@ -269,6 +272,13 @@ it.effect("classifies projection infrastructure failures separately from a missi cause: infrastructureCause, }); const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadProjection: () => Effect.fail(projectionError), @@ -302,6 +312,13 @@ it.effect("uses thread-not-found only after a projection loads outside the proje }, } as OrchestrationV2ThreadProjection; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadProjection: () => Effect.succeed(projection), @@ -327,6 +344,13 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte cause: new Error("checkpoint import failed"), }); const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), Layer.provide( Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({ @@ -368,6 +392,13 @@ it.effect.each([ runs: status === "missing" ? [] : [{ id: runId, status }], }) as unknown as OrchestrationV2ThreadProjection; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadEventSequence: () => Effect.succeed(0), @@ -434,6 +465,13 @@ it.effect("waitForThread reads the run again only when the run updates", () => const stored = (sequence: number, event: object) => ({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadEventSequence: () => Effect.succeed(0), @@ -485,6 +523,13 @@ it.effect.each([ const runId = RunId.make("run:thread-management:settle-after-run"); const dispatched: Array = []; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadEventSequence: () => Effect.succeed(0), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index a12e6680ca7e2..f71df3932d5ef 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -9,6 +9,7 @@ import type { } from "./ProjectionStore.ts"; import { type ChatAttachment, + type GitHubReplySnooze, CommandId, MessageId, type ModelSelection, @@ -29,6 +30,7 @@ import { ThreadId, type TurnItemId, } from "@t3tools/contracts"; +import { isLinkedOpenGitHubPullRequest } from "@t3tools/shared/threadPullRequests"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -39,6 +41,9 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Orchestrator from "./Orchestrator.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import { projectTurnItemForDetail } from "./WireProjection.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; @@ -441,6 +446,9 @@ const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000; const make = Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; + const githubReplies = yield* GitHubReplyReader.GitHubReplyReader; + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + const projects = yield* ProjectStore.ProjectStoreV2; const layerScope = yield* Effect.scope; const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; @@ -509,8 +517,101 @@ const make = Effect.gen(function* () { Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)), ); - const dispatch: ThreadManagementServiceShape["dispatch"] = (command) => - ensureCommandTranscripts(command).pipe(Effect.andThen(orchestrator.dispatch(command))); + const dispatch: ThreadManagementServiceShape["dispatch"] = Effect.fn( + "orchestrationV2.threadManagement.dispatch", + )(function* (command) { + yield* ensureCommandTranscripts(command); + if (command.type === "thread.github-reply.snooze" && command.resumeFrom === undefined) { + const receipt = yield* receipts.getByCommandId(command.commandId).pipe( + Effect.mapError( + (cause) => + new Orchestrator.OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ); + // Let the orchestrator validate and replay the receipt even after the watch has woken. + if (Option.isSome(receipt)) return yield* orchestrator.dispatch(command); + const snapshotSequence = yield* orchestrator.getThreadEventSequence(command.threadId); + const thread = yield* orchestrator.getThreadShell(command.threadId); + const project = + thread === null + ? Option.none() + : yield* projects.get(thread.projectId).pipe( + Effect.mapError( + (cause) => + new Orchestrator.OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ); + if (!thread || Option.isNone(project)) { + return yield* new Orchestrator.OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The thread's project is unavailable.", + }); + } + if ( + command.wakeCondition === "changes-requested" && + !isLinkedOpenGitHubPullRequest(thread.pullRequests ?? [], command.url) + ) { + return yield* new Orchestrator.OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Changes-requested snooze requires a linked open github.com pull request.", + }); + } + const now = DateTime.formatIso(yield* DateTime.now); + const watch: GitHubReplySnooze = { + requestId: command.commandId, + url: command.url, + wakeCondition: command.wakeCondition, + startedAt: now, + nextCheckAt: now, + status: "pending", + failures: 0, + }; + const observed = yield* githubReplies + .read({ + cwd: thread.worktreePath ?? project.value.workspaceRoot, + watch, + captureBaseline: true, + }) + .pipe( + Effect.mapError( + (cause) => + new Orchestrator.OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ); + return yield* orchestrator.dispatch( + { + ...command, + resumeFrom: { + ...watch, + baseline: observed.baseline, + viewer: observed.viewer, + ...(observed.discussionCommentId === undefined + ? {} + : { discussionCommentId: observed.discussionCommentId }), + }, + }, + { + githubReplySnoozeAfterSequence: snapshotSequence, + githubReplySnoozeRecoveryRequestId: thread.limitRecovery?.requestId ?? null, + }, + ); + } + return yield* orchestrator.dispatch(command); + }); const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) => getThreadProjection(input.threadId).pipe( @@ -939,11 +1040,8 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed( }), ); -export const layer: Layer.Layer = - Layer.effect(ThreadManagementService, make).pipe(Layer.provide(layerLegacyV1ThreadImporterNoop)); +export const layer = Layer.effect(ThreadManagementService, make).pipe( + Layer.provide(layerLegacyV1ThreadImporterNoop), +); -export const layerWithLegacyImporter: Layer.Layer< - ThreadManagementService, - never, - LegacyV1ThreadImporter.LegacyV1ThreadImporter | Orchestrator.OrchestratorV2 -> = Layer.effect(ThreadManagementService, make); +export const layerWithLegacyImporter = Layer.effect(ThreadManagementService, make); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 4b0e451e0ae66..1b8089e1abe76 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -2,6 +2,8 @@ import { assert, describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import { DEFAULT_SERVER_SETTINGS, + CommandId, + RuntimeRequestId, ProjectId, EventId, ProviderInstanceId, @@ -1254,6 +1256,63 @@ describe("ThreadSettlementServiceV2 single-thread sweeps", () => { }); describe("isSnoozed", () => { + const replySnoozed = shell({ + snoozedAt: at(-2 * DAY_MS), + snoozedUntil: null, + githubReplySnooze: { + requestId: CommandId.make("reply-watch"), + url: "https://github.com/team/repo/pull/1", + startedAt: DateTime.formatIso(at(-2 * DAY_MS)), + nextCheckAt: DateTime.formatIso(at(0)), + status: "watching", + failures: 0, + }, + }); + it("keeps a reply watch snoozed through completion and old failures", () => { + expect(ThreadSettlementService.isSnoozed(replySnoozed, NOW_MS)).toBe(true); + expect( + ThreadSettlementService.isSnoozed( + { + ...replySnoozed, + status: "completed", + latestRunCompletedAt: at(-DAY_MS), + }, + NOW_MS, + ), + ).toBe(true); + expect( + ThreadSettlementService.isSnoozed( + { + ...replySnoozed, + status: "failed", + latestRunCompletedAt: at(-3 * DAY_MS), + }, + NOW_MS, + ), + ).toBe(true); + expect( + ThreadSettlementService.isSnoozed( + { + ...replySnoozed, + status: "failed", + latestRunCompletedAt: at(-DAY_MS), + }, + NOW_MS, + ), + ).toBe(false); + }); + it.each(["command", "user_input"] as const)("wakes a reply watch for %s", (kind) => { + expect( + ThreadSettlementService.isSnoozed( + { + ...replySnoozed, + pendingRuntimeRequest: { id: RuntimeRequestId.make("request"), kind, createdAt: at(0) }, + }, + NOW_MS, + ), + ).toBe(false); + }); + const snoozed = { snoozedAt: at(-2 * DAY_MS), snoozedUntil: at(DAY_MS) }; it("wakes for completed work after the snooze, but not an interrupted run", () => { expect(ThreadSettlementService.isSnoozed(shell(snoozed), NOW_MS)).toBe(true); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 5e3f7e7956006..dbd182475f420 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -141,6 +141,7 @@ export function isAutoSettlementCandidate( nowMs: number, ): boolean { if (thread.archivedAt !== null || thread.settledOverride !== null) return false; + if (thread.githubReplySnooze != null) return false; if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false; // Blocked-on-you work must never park behind a settled override. if (thread.pendingRuntimeRequest !== null) return false; @@ -164,7 +165,7 @@ export function isAutoSettlementCandidate( } /** - * Whether a thread is parked on its snooze: its wake time is in the future and + * Whether a thread is parked on a future wake time or a GitHub reply watch and * it has not raised its hand with a pending request, a fresh failure, or work * that completed after the snooze. Server twin of the client's * `effectiveSnoozed`, so agents and the sidebar agree on what is snoozed. One @@ -175,12 +176,18 @@ export function isAutoSettlementCandidate( export function isSnoozed( thread: Pick< ProjectionStore.ProjectionSettlementCandidate, - "snoozedUntil" | "snoozedAt" | "latestRunCompletedAt" | "status" | "pendingRuntimeRequest" + | "snoozedUntil" + | "snoozedAt" + | "githubReplySnooze" + | "latestRunCompletedAt" + | "status" + | "pendingRuntimeRequest" >, nowMs: number, ): boolean { const snoozedUntilMs = toMillis(thread.snoozedUntil); - if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return false; + if (thread.githubReplySnooze == null && (snoozedUntilMs === null || snoozedUntilMs <= nowMs)) + return false; if (thread.pendingRuntimeRequest !== null) return false; const snoozedAtMs = toMillis(thread.snoozedAt); const completedAtMs = toMillis(thread.latestRunCompletedAt); @@ -189,6 +196,7 @@ export function isSnoozed( (snoozedAtMs === null || (completedAtMs !== null && completedAtMs > snoozedAtMs)); // Like the client, only a run that completed wakes it; an interrupt or cancel does not. const wokeOnCompletion = + thread.githubReplySnooze == null && thread.status === "completed" && snoozedAtMs !== null && completedAtMs !== null && diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 43a77a50e75c0..41ac4c3672fd0 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -1,3 +1,6 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import { assert, it } from "@effect/vitest"; import { CommandId, @@ -41,6 +44,13 @@ const adapter = { const layerDatabase = SqlitePersistence.layerMemory; // No effect worker: runs stay unstarted, so Stop ends them without a provider. const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), Layer.provideMerge( Layer.mergeAll( layerDatabase, diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 392dc3fdaee75..1ba9bbcbec997 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -1,3 +1,5 @@ +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import { assert, describe, it, vi } from "@effect/vitest"; import { type ChatAttachment, @@ -54,7 +56,16 @@ function makeHarness( layerRegistry, { databaseLayer: layerDatabase, runEffectWorker: false }, ); - const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator)); + const layerThreadManagement = ThreadManagement.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ), + ), + Layer.provide(layerOrchestrator), + ); const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); const generateThreadTitle = vi.fn( options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })), diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index a7fe7c2902ec5..739be12c9cc11 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -15,6 +15,8 @@ export function limitRecoveryCommand( snooze = false, ): OrchestrationV2Command | null { if ( + thread.githubReplySnooze != null || + thread.githubReplyAutoResumeBlockedRunId === thread.latestRunId || thread.status !== "failed" || thread.lastErrorClass !== "usage_limit" || !thread.latestRunId || @@ -39,6 +41,7 @@ export function limitRecoveryCommand( type: "thread.metadata.update", commandId: CommandId.make(`limit-arm:${identity}`), threadId: thread.id, + automaticLimitRecovery: true, limitRecovery: { runId: thread.latestRunId, resetAt: thread.usageLimitResetAt, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index aed7f73e6c0f7..a9fbd459b6317 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1,3 +1,8 @@ +import * as ThreadDiscussionService from "./ThreadDiscussionService.ts"; +import * as GitHubDiscussions from "../sourceControl/GitHubDiscussions.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import * as GitHubReplySnoozeWorker from "./GitHubReplySnoozeWorker.ts"; +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -78,7 +83,8 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; -const layerPlatformTest = Layer.merge( +const layerPlatformTest = Layer.mergeAll( + Layer.mock(GitHubReplyReader.GitHubReplyReader)({}), NodeServices.layer, Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused title link"), @@ -2116,6 +2122,172 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect( + "persists discussion links independently of pull requests and keeps comment anchors idempotent", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const threadId = ThreadId.make("runtime-discussion-links"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("discussion-thread"), + threadId, + projectId: ProjectId.make("discussion-project"), + title: "Discussion links", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const discussion = { + host: "github.com" as const, + repository: "Team/Repo", + number: 1, + url: "https://github.com/Team/Repo/discussions/1#discussioncomment-10", + }; + for (const [index, url] of [ + discussion.url, + "https://github.com/team/repo/discussions/1#discussioncomment-20", + ].entries()) { + yield* orchestrator.dispatch({ + type: "thread.discussion.link", + commandId: CommandId.make(`discussion-link-${index}`), + threadId, + discussion: { ...discussion, url }, + title: "A discussion", + source: "agent", + }); + } + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make("discussion-pr-link"), + threadId, + host: "github.com", + repository: "team/repo", + number: 1, + url: "https://github.com/team/repo/pull/1", + source: "manual", + }); + assert.isTrue((yield* maintenance.rebuild).valid); + const linked = yield* orchestrator.getThreadShell(threadId); + assert.equal(linked?.discussions?.length, 1); + assert.equal(linked?.discussions?.[0]?.url, "https://github.com/team/repo/discussions/1"); + assert.equal(linked?.discussions?.[0]?.title, "A discussion"); + assert.equal(linked?.pullRequests?.length, 1); + assert.isNull(linked?.activeRunId); + assert.isNull(linked?.settledAt); + for (let index = 0; index < 2; index++) { + yield* orchestrator.dispatch({ + type: "thread.discussion.unlink", + commandId: CommandId.make(`discussion-unlink-${index}`), + threadId, + discussion, + }); + } + assert.isTrue((yield* maintenance.rebuild).valid); + const unlinked = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual(unlinked?.discussions, []); + assert.equal(unlinked?.pullRequests?.length, 1); + const mismatch = yield* orchestrator + .dispatch({ + type: "thread.discussion.link", + commandId: CommandId.make("discussion-mismatch"), + threadId, + discussion: { ...discussion, repository: "other/repo" }, + source: "agent", + }) + .pipe(Effect.flip); + assert.equal(mismatch._tag, "OrchestratorDispatchError"); + }), + ); + + it.effect( + "links, lists and unlinks discussions through the service with one lightweight GitHub read", + () => { + const reads: Array<{ readonly cwd: string; readonly url: string }> = []; + return Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const threadId = ThreadId.make("discussion-service-thread"); + const projectId = ProjectId.make("discussion-service-project"); + yield* seedProject({ + projectId, + title: "Discussion project", + workspaceRoot: "/workspace/discussions", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("discussion-service-create"), + threadId, + projectId, + title: "Discussion", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/missing-discussion-worktree", + }); + const service = yield* ThreadDiscussionService.ThreadDiscussionService; + const first = yield* service.link({ + threadId, + url: "https://github.com/Team/Repo/discussions/1#discussioncomment-10", + }); + assert.equal(first.alreadyLinked, false); + assert.equal( + (yield* service.link({ threadId, repository: "team/repo", number: 1 })).alreadyLinked, + true, + ); + assert.deepEqual(reads, [ + { cwd: "/workspace/discussions", url: "https://github.com/team/repo/discussions/1" }, + ]); + assert.isTrue((yield* maintenance.rebuild).valid); + const listed = yield* service.list(threadId); + assert.equal(listed.discussions.length, 1); + assert.equal(listed.discussions[0]?.title, "A discussion"); + assert.equal( + (yield* service.unlink({ threadId, repository: "Team/Repo", number: 1 })).wasLinked, + true, + ); + assert.equal((yield* service.unlink({ threadId, url: first.url })).wasLinked, false); + assert.deepEqual((yield* service.list(threadId)).discussions, []); + for (const input of [ + { repository: "team/repo", number: 1, host: "other.example" }, + { repository: "team/repo/extra", number: 1 }, + { repository: "team/repo" }, + ]) { + assert.equal( + (yield* service.link({ threadId, ...input }).pipe(Effect.flip))._tag, + "DiscussionOperationError", + ); + } + assert.equal(reads.length, 1); + }).pipe( + Effect.provide( + ThreadDiscussionService.layer.pipe( + Layer.provide( + Layer.mock(GitHubDiscussions.GitHubDiscussions)({ + summary: (input) => + Effect.sync(() => { + reads.push(input); + return { title: "A discussion" }; + }), + }), + ), + Layer.provide(layerPlatformTest), + ), + ), + ); + }, + ); + it.effect("retains multiple pull requests and dismissed stack members through rebuilds", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -2302,6 +2474,150 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each(["idle", "running"] as const)( + "GitHub reply snooze ends PR watches and rejects stale wakes: %s", + (scenario) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`reply-snooze-pr-watch:${scenario}`); + const projectId = ProjectId.make(`${threadId}:project`); + yield* seedProject({ + projectId, + title: "Reply snooze", + workspaceRoot: "/tmp/reply-snooze-pr-watch", + defaultModelSelection: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId, + title: "Reply snooze", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const now = yield* DateTime.now; + if (scenario === "running") { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:user-message`), + messageId: MessageId.make(`${threadId}:user-message`), + threadId, + text: "Original work", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + yield* sink.write({ + events: [ + { + id: EventId.make(`${threadId}:running`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "running", startedAt: now }, + }, + ], + }); + } + const key = { host: "github.com", repository: "team/repo", number: 1 }; + const url = "https://github.com/team/repo/pull/1"; + const watchCommand = { + type: "thread.pull-request.watch" as const, + commandId: CommandId.make(`${threadId}:watch`), + threadId, + ...key, + watching: true, + link: { url, source: "agent" as const }, + }; + yield* orchestrator.dispatch(watchCommand); + const watch = (yield* orchestrator.getThreadShell(threadId))!.pullRequests![0]!.watch!; + const before = yield* orchestrator.getThreadProjection(threadId); + const requestId = CommandId.make(`${threadId}:snooze`); + yield* orchestrator.dispatch({ + type: "thread.github-reply.snooze", + commandId: requestId, + threadId, + url, + }); + const snoozed = yield* orchestrator.getThreadProjection(threadId); + assert.isUndefined(snoozed.thread.pullRequests?.[0]?.watch); + assert.deepEqual(snoozed.runs, before.runs); + const register = yield* orchestrator + .dispatch({ + ...watchCommand, + commandId: CommandId.make(`${threadId}:watch-after-snooze`), + }) + .pipe(Effect.result); + assert.equal(register._tag, "Failure"); + if (scenario === "running") { + const run = snoozed.runs[0]!; + yield* sink.write({ + events: [ + { + id: EventId.make(`${threadId}:completed`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "completed", completedAt: now }, + }, + ], + }); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs[0]?.status, + "completed", + ); + } + for (const stage of ["snoozed", "reply-received"] as const) { + if (stage === "reply-received") { + yield* orchestrator.dispatch({ + type: "thread.github-reply.sync", + commandId: CommandId.make(`${threadId}:reply`), + threadId, + requestId, + watch: null, + notice: { type: "reply", receivedAt: DateTime.formatIso(now), url, text: "Feedback" }, + }); + } + const wake = yield* orchestrator + .dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`${threadId}:stale-watch:${stage}`), + threadId, + ...key, + startedAt: watch.startedAt, + watch: { ...watch, failedChecks: ["lint"], wakes: 1 }, + wake: { + messageId: MessageId.make(`${threadId}:stale-wake:${stage}`), + text: "CI failed", + notification: { source: { kind: "monitor" }, outcome: "updated", summary: "#1" }, + }, + }) + .pipe(Effect.result); + assert.equal(wake._tag, "Failure"); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.lengthOf(after.runs, before.runs.length); + assert.deepEqual(after.messages, before.messages); + assert.isUndefined(after.thread.pullRequests?.[0]?.watch); + assert.equal( + after.thread.githubReplySnooze?.requestId ?? null, + stage === "snoozed" ? requestId : null, + ); + } + }), + ); + it.effect.each(["manual", "automatic"])("settling ends every pull request watch: %s", (mode) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -3229,6 +3545,116 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each(["snoozed", "reply-received"] as const)( + "atomically declines restart continuation after GitHub reply snooze: %s", + (scenario) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`reply-snooze-restart:${scenario}`); + const projectId = ProjectId.make(`${threadId}:project`); + yield* seedProject({ + projectId, + title: "Reply snooze", + workspaceRoot: "/tmp/reply-snooze-restart", + defaultModelSelection: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId, + title: "Reply snooze", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:user-message`), + messageId: MessageId.make(`${threadId}:user-message`), + threadId, + text: "Original work", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const original = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + const url = "https://github.com/team/repo/pull/1"; + const requestId = CommandId.make(`${threadId}:snooze`); + yield* orchestrator.dispatch({ + type: "thread.github-reply.snooze", + commandId: requestId, + threadId, + url, + }); + const now = yield* DateTime.now; + yield* sink.commitCommand({ + commandId: CommandId.make(`${threadId}:cancel`), + threadId, + commandType: "provider-runtime.reconcile", + acceptedAt: now, + events: [ + { + id: EventId.make(`${threadId}:cancelled`), + type: "run.updated", + threadId, + runId: original.id, + occurredAt: now, + payload: { ...original, status: "cancelled", completedAt: now }, + }, + ], + effects: [], + }); + if (scenario === "reply-received") { + yield* orchestrator.dispatch({ + type: "thread.github-reply.sync", + commandId: CommandId.make(`${threadId}:reply`), + threadId, + requestId, + watch: null, + notice: { type: "reply", receivedAt: DateTime.formatIso(now), url, text: "Feedback" }, + }); + } + const before = yield* orchestrator.getThreadProjection(threadId); + const automatic = { + type: "message.dispatch" as const, + commandId: CommandId.make(`${threadId}:automatic`), + messageId: MessageId.make(`${threadId}:automatic`), + threadId, + text: "Continue where you left off.", + attachments: [], + dispatchMode: { type: "start_immediately" as const }, + restartContinuationOfRunId: original.id, + createdBy: "agent" as const, + creationSource: "server" as const, + }; + yield* orchestrator.dispatch(automatic); + yield* orchestrator.dispatch(automatic); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(after.thread, before.thread); + assert.deepEqual(after.runs, before.runs); + assert.deepEqual(after.messages, before.messages); + yield* orchestrator.dispatch({ + ...automatic, + commandId: CommandId.make(`${threadId}:explicit`), + messageId: MessageId.make(`${threadId}:explicit`), + restartContinuationOfRunId: undefined, + createdBy: "user", + creationSource: "web", + }); + const resumed = yield* orchestrator.getThreadProjection(threadId); + assert.lengthOf(resumed.runs, 2); + assert.isNull(resumed.thread.githubReplySnooze); + }), + ); + it.effect.each(["failed", "completed", "waiting", "cancelled"] as const)( "does not admit a restart continuation of a %s run with only lost background work", (status) => @@ -4911,6 +5337,17 @@ it.layer(layerTest)("usage-limit recovery", (it) => { it.effect.each([ "resume", "queued-resume", + "github-running", + "github-steer-snoozed", + "github-steer-woke", + "github-restart-snoozed", + "github-restart-woke", + "github-limited", + "github-arm-while-snoozed", + "github-arm-after-wake", + "github-manual-rearm-while-snoozed", + "github-manual-rearm-after-wake", + "github-manual-rearm-after-reply", "cancel", "rearm", "snooze-race", @@ -4932,6 +5369,8 @@ it.layer(layerTest)("usage-limit recovery", (it) => { Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const events = yield* EventSink.EventSinkV2; + const sameRunReplyMessage = + scenario.startsWith("github-steer-") || scenario.startsWith("github-restart-"); const threadId = ThreadId.make(`recovery:${scenario}`); const projectId = ProjectId.make(`recovery:project:${scenario}`); yield* seedProject({ @@ -4962,7 +5401,7 @@ it.layer(layerTest)("usage-limit recovery", (it) => { messageId: MessageId.make(`recovery:message:${scenario}`), text: "Work on this.", attachments: [], - dispatchMode: { type: "defer_start" }, + dispatchMode: { type: sameRunReplyMessage ? "start_immediately" : "defer_start" }, createdBy: "user", creationSource: "web", }); @@ -4980,39 +5419,165 @@ it.layer(layerTest)("usage-limit recovery", (it) => { }); } const projection = yield* orchestrator.getThreadProjection(threadId); - const run = projection.runs[0]!; - const now = yield* DateTime.now; - const resetAt = - scenario === "invalid-snooze" - ? "not-a-date" - : DateTime.formatIso(DateTime.add(now, { minutes: 1 })).replace( - "Z", - scenario === "wake" ? "+00:00" : "Z", - ); - yield* events.write({ - commandId: CommandId.make(`recovery:failure:${scenario}`), - events: [ - { - id: EventId.make(`recovery:run:${scenario}`), - type: "run.updated", - threadId, - occurredAt: now, - payload: { ...run, status: "failed", completedAt: now }, - }, - { - id: EventId.make(`recovery:error:${scenario}`), - type: "turn-item.updated", - threadId, - occurredAt: now, - payload: { - id: TurnItemId.make(`recovery:error:${scenario}`), - type: "error", - threadId, - runId: run.id, - nodeId: run.rootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, + let run = projection.runs[0]!; + const snoozeForReply = { + type: "thread.github-reply.snooze" as const, + commandId: CommandId.make(`recovery:github-snooze:${scenario}`), + threadId, + url: "https://github.com/team/repo/pull/1", + }; + if (sameRunReplyMessage) { + const now = yield* DateTime.now; + const providerThread = projection.providerThreads[0]!; + const providerSession = { + id: providerThread.providerSessionId!, + driver, + providerInstanceId: modelSelection.instanceId, + status: "running" as const, + cwd: process.cwd(), + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + yield* events.write({ + events: [ + { + id: EventId.make(`${threadId}:running`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "running", startedAt: now }, + }, + { + id: EventId.make(`${threadId}:session`), + type: "provider-session.attached", + threadId, + occurredAt: now, + payload: providerSession, + }, + { + id: EventId.make(`${threadId}:turn`), + type: "provider-turn.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { + id: ProviderTurnId.make(`${threadId}:turn`), + providerThreadId: providerThread.id, + nodeId: run.rootNodeId!, + runAttemptId: run.activeAttemptId, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }, + ], + }); + const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const sessionSpy = vi + .spyOn(sessions, "get") + .mockReturnValue( + Effect.succeed(Option.some({ providerSession } as ProviderAdapterV2SessionRuntime)), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => sessionSpy.mockRestore())); + yield* orchestrator.dispatch(snoozeForReply); + if (scenario.endsWith("-woke")) + yield* orchestrator.dispatch({ + type: "thread.github-reply.sync", + commandId: CommandId.make(`${threadId}:reply`), + threadId, + requestId: snoozeForReply.commandId, + watch: null, + notice: { + type: "reply", + receivedAt: DateTime.formatIso(now), + url: snoozeForReply.url, + text: "Feedback", + }, + }); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).thread + .githubReplyAutoResumeBlockedRunId, + run.id, + ); + const message = { + type: "message.dispatch" as const, + threadId, + text: "Continue with this feedback", + attachments: [], + dispatchMode: { + type: scenario.startsWith("github-steer-") + ? ("steer_active" as const) + : ("restart_active" as const), + targetRunId: run.id, + }, + createdBy: "user" as const, + creationSource: "web" as const, + }; + const rejected = yield* orchestrator + .dispatch({ + ...message, + commandId: CommandId.make(`${threadId}:invalid-message`), + messageId: MessageId.make(`${threadId}:invalid-message`), + dispatchMode: { ...message.dispatchMode, targetRunId: RunId.make("missing-run") }, + }) + .pipe(Effect.result); + assert.equal(rejected._tag, "Failure"); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).thread + .githubReplyAutoResumeBlockedRunId, + run.id, + ); + yield* orchestrator.dispatch({ + ...message, + commandId: CommandId.make(`${threadId}:explicit-message`), + messageId: MessageId.make(`${threadId}:explicit-message`), + }); + const steered = yield* orchestrator.getThreadProjection(threadId); + assert.lengthOf(steered.runs, 1); + assert.equal(steered.runs[0]!.id, run.id); + assert.isNull(steered.thread.githubReplySnooze); + assert.isNull(steered.thread.githubReplyAutoResumeBlockedRunId); + run = steered.runs[0]!; + } + if (scenario === "github-running") yield* orchestrator.dispatch(snoozeForReply); + const now = yield* DateTime.now; + const resetAt = + scenario === "invalid-snooze" + ? "not-a-date" + : DateTime.formatIso(DateTime.add(now, { minutes: 1 })).replace( + "Z", + scenario === "wake" ? "+00:00" : "Z", + ); + yield* events.write({ + commandId: CommandId.make(`recovery:failure:${scenario}`), + events: [ + { + id: EventId.make(`recovery:run:${scenario}`), + type: "run.updated", + threadId, + occurredAt: now, + payload: { ...run, status: "failed", completedAt: now }, + }, + { + id: EventId.make(`recovery:error:${scenario}`), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make(`recovery:error:${scenario}`), + type: "error", + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, parentItemId: null, ordinal: 2, status: "failed", @@ -5059,6 +5624,242 @@ it.layer(layerTest)("usage-limit recovery", (it) => { (thread) => thread.id === threadId, )!; assert.isNull(limitRecoveryCommand(shell, false, DateTime.toEpochMillis(now))); + if ( + scenario === "github-manual-rearm-while-snoozed" || + scenario === "github-manual-rearm-after-wake" || + scenario === "github-manual-rearm-after-reply" + ) { + yield* orchestrator.dispatch( + limitRecoveryCommand(shell, true, DateTime.toEpochMillis(now))!, + ); + const staleResume = limitRecoveryCommand( + (yield* orchestrator.getThreadShell(threadId))!, + true, + Date.parse(resetAt), + ); + assert.isNotNull(staleResume); + yield* orchestrator.dispatch(snoozeForReply); + if (scenario === "github-manual-rearm-after-wake") { + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make(`recovery:github-wake:${scenario}`), + threadId, + reason: "user", + }); + } else if (scenario === "github-manual-rearm-after-reply") { + yield* orchestrator.dispatch({ + type: "thread.github-reply.sync", + commandId: CommandId.make(`recovery:github-reply:${scenario}`), + threadId, + requestId: snoozeForReply.commandId, + watch: null, + notice: { + type: "reply", + url: snoozeForReply.url, + text: "Feedback", + receivedAt: DateTime.formatIso(now), + }, + }); + } + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).thread + .githubReplyAutoResumeBlockedRunId, + run.id, + ); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`recovery:github-manual-arm:${scenario}`), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: true }, + }); + const armed = yield* orchestrator.getThreadProjection(threadId); + assert.isNull(armed.thread.githubReplyAutoResumeBlockedRunId); + assert.isNull(armed.thread.githubReplySnooze); + assert.isNull(armed.thread.snoozedAt); + assert.isTrue(armed.thread.limitRecovery?.autoResume); + assert.lengthOf(armed.runs, 1); + yield* TestClock.adjust("1 minute"); + yield* orchestrator.dispatch(staleResume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1); + const resume = limitRecoveryCommand( + (yield* orchestrator.getThreadShell(threadId))!, + true, + Date.parse(resetAt), + ); + assert.isNotNull(resume); + yield* orchestrator.dispatch(resume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); + return; + } + if (scenario === "github-arm-while-snoozed" || scenario === "github-arm-after-wake") { + const staleArm = limitRecoveryCommand(shell, true, DateTime.toEpochMillis(now), true); + assert.isNotNull(staleArm); + yield* orchestrator.dispatch(snoozeForReply); + if (scenario === "github-arm-after-wake") { + yield* orchestrator.dispatch({ + type: "thread.github-reply.sync", + commandId: CommandId.make(`recovery:github-wake:${scenario}`), + threadId, + requestId: snoozeForReply.commandId, + watch: null, + notice: { + type: "reply", + url: snoozeForReply.url, + text: "Feedback", + receivedAt: DateTime.formatIso(now), + }, + }); + } + const before = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + before.thread.githubReplySnooze?.requestId ?? null, + scenario === "github-arm-while-snoozed" ? snoozeForReply.commandId : null, + ); + assert.equal(before.thread.githubReplyAutoResumeBlockedRunId, run.id); + assert.isNull(before.thread.limitRecovery); + assert.isNull(before.thread.snoozedUntil); + const result = yield* orchestrator.dispatch(staleArm!).pipe(Effect.exit); + assert.equal(result._tag, "Failure"); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(after.thread, before.thread); + assert.deepEqual(after.runs, before.runs); + assert.deepEqual(after.messages, before.messages); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`recovery:github-manual-snooze:${scenario}`), + threadId, + limitRecovery: { runId: run.id, resetAt, snooze: true }, + }); + const manuallySnoozed = yield* orchestrator.getThreadProjection(threadId); + assert.isNull(manuallySnoozed.thread.githubReplySnooze); + assert.equal( + DateTime.toEpochMillis(manuallySnoozed.thread.snoozedUntil!), + Date.parse(resetAt), + ); + assert.isTrue(manuallySnoozed.thread.limitRecovery?.snooze); + assert.isFalse(manuallySnoozed.thread.limitRecovery?.autoResume); + assert.lengthOf(manuallySnoozed.runs, 1); + return; + } + if (scenario === "github-running" || scenario === "github-limited") { + let staleResume = null; + if (scenario === "github-limited") { + yield* orchestrator.dispatch( + limitRecoveryCommand(shell, true, DateTime.toEpochMillis(now))!, + ); + staleResume = limitRecoveryCommand( + (yield* orchestrator.getThreadShell(threadId))!, + true, + Date.parse(resetAt), + ); + assert.isNotNull(staleResume); + yield* orchestrator.dispatch(snoozeForReply); + } + yield* orchestrator.dispatch({ + type: "thread.github-reply.sync", + commandId: CommandId.make(`recovery:github-wake:${scenario}`), + threadId, + requestId: snoozeForReply.commandId, + watch: null, + notice: { + type: "reply", + url: snoozeForReply.url, + text: "Feedback", + receivedAt: DateTime.formatIso(now), + }, + }); + yield* TestClock.adjust("1 minute"); + const projections = yield* ProjectionStore.ProjectionStoreV2; + const getShell = orchestrator.getThreadShell(threadId); + const woke = (yield* getShell)!; + assert.isNull(woke.githubReplySnooze); + assert.equal( + (yield* projections.getThread(threadId)).githubReplyAutoResumeBlockedRunId, + run.id, + ); + assert.isNull(limitRecoveryCommand(woke, true, Date.parse(resetAt))); + // a stale worker cannot re-arm this run after the reply, even after the reset. + const staleArm = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`recovery:github-stale-arm:${scenario}`), + threadId, + automaticLimitRecovery: true, + limitRecovery: { runId: run.id, resetAt, autoResume: true }, + }) + .pipe(Effect.exit); + assert.equal(staleArm._tag, "Failure"); + assert.isNull((yield* getShell)!.limitRecovery); + if (staleResume !== null) yield* orchestrator.dispatch(staleResume); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1); + const candidates = yield* projections.getLimitRecoveryCandidates({ + now: yield* DateTime.now, + autoResume: true, + snooze: false, + }); + const candidate = candidates.find((candidate) => candidate.id === threadId); + if (candidate) assert.isNull(limitRecoveryCommand(candidate, true, Date.parse(resetAt))); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`recovery:github-manual:${scenario}`), + messageId: MessageId.make(`recovery:github-manual:${scenario}`), + threadId, + manualContinuationOfRunId: run.id, + text: "Continue where you left off.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const continued = yield* orchestrator.getThreadProjection(threadId); + assert.lengthOf(continued.runs, 2); + const nextRun = continued.runs[1]!; + const nextNow = yield* DateTime.now; + const nextReset = DateTime.formatIso(DateTime.add(nextNow, { minutes: 1 })); + const error = continued.turnItems.find((item) => item.type === "error")!; + if (error.type !== "error") throw new Error("Expected usage limit error"); + yield* events.write({ + events: [ + { + id: EventId.make(`recovery:github-next-run:${scenario}`), + type: "run.updated", + threadId, + occurredAt: nextNow, + payload: { ...nextRun, status: "failed", completedAt: nextNow }, + }, + { + id: EventId.make(`recovery:github-next-error:${scenario}`), + type: "turn-item.updated", + threadId, + occurredAt: nextNow, + payload: { + ...error, + id: TurnItemId.make(`recovery:github-next-error:${scenario}`), + runId: nextRun.id, + nodeId: nextRun.rootNodeId, + ordinal: 4, + startedAt: nextNow, + completedAt: nextNow, + updatedAt: nextNow, + failure: { ...error.failure, resetAt: nextReset }, + }, + }, + ], + }); + const nextArm = limitRecoveryCommand( + (yield* getShell)!, + true, + DateTime.toEpochMillis(nextNow), + ); + assert.isNotNull(nextArm); + yield* orchestrator.dispatch(nextArm!); + yield* TestClock.adjust("1 minute"); + const nextResume = limitRecoveryCommand((yield* getShell)!, true, Date.parse(nextReset)); + assert.isNotNull(nextResume); + yield* orchestrator.dispatch(nextResume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 3); + return; + } if (scenario === "invalid-snooze") { const result = yield* orchestrator .dispatch({ @@ -5358,7 +6159,8 @@ it.layer(layerTest)("usage-limit recovery", (it) => { assert.lengthOf( after.runs, before.runs.length + - (scenario === "resume" || + (sameRunReplyMessage || + scenario === "resume" || scenario === "queued-resume" || scenario === "snooze-resume" || scenario === "wake-preserve-resume" || @@ -5370,7 +6172,8 @@ it.layer(layerTest)("usage-limit recovery", (it) => { assert.lengthOf( after.messages, before.messages.length + - (scenario === "resume" || + (sameRunReplyMessage || + scenario === "resume" || scenario === "queued-resume" || scenario === "snooze-resume" || scenario === "wake-preserve-resume" || @@ -5408,3 +6211,816 @@ it.layer(layerTest)("usage-limit recovery", (it) => { }), ); }); + +it.layer(layerSharedApplicationDataPlaneTest)("GitHub reply snooze", (it) => { + it.effect.each([ + "discussion", + "unlinked", + "closed", + "merged", + "unknown-state", + "other-host", + "mismatched-identity", + "dismissed", + "unsupported-url", + ] as const)( + "rejects changes-requested snooze for %s before reading GitHub, including restored watches", + (scenario) => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projectId = ProjectId.make(`changes-invalid:${scenario}:project`); + const threadId = ThreadId.make(`changes-invalid:${scenario}:thread`); + yield* projects.create({ + commandId: CommandId.make(`${projectId}:create`), + projectId, + title: "Invalid target", + workspaceRoot: `/tmp/${projectId}`, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId, + title: "Invalid target", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const key = { + host: scenario === "other-host" ? "ghe.example" : "github.com", + repository: scenario === "mismatched-identity" ? "other/repo" : "team/repo", + number: 1, + }; + if (scenario !== "unlinked") { + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make(`${threadId}:link`), + threadId, + ...key, + url: "https://github.com/team/repo/pull/1", + source: scenario === "dismissed" ? "stack-dismissed" : "manual", + }); + if (scenario !== "unknown-state") + yield* orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make(`${threadId}:sync`), + threadId, + ...key, + snapshot: { + state: scenario === "closed" || scenario === "merged" ? scenario : "open", + title: "PR", + headBranch: "work", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: DateTime.formatIso(yield* DateTime.now), + }, + stack: null, + }); + } + let reads = 0; + const service = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide( + ThreadManagementService.layer.pipe( + Layer.provide( + Layer.succeed(GitHubReplyReader.GitHubReplyReader, { + read: () => + Effect.sync(() => { + reads++; + return { viewer: "me", baseline: { latestAt: null, ids: [] }, reply: null }; + }), + }), + ), + Layer.provide(CommandReceiptStore.layerFromApplicationReceipts), + Layer.provide(ProjectStore.layer), + ), + ), + ); + const command = { + type: "thread.github-reply.snooze" as const, + commandId: CommandId.make(`${threadId}:snooze`), + threadId, + url: + scenario === "discussion" + ? "https://github.com/team/repo/discussions/1" + : scenario === "unsupported-url" + ? "https://example.com/arbitrary" + : "https://github.com/team/repo/pull/1", + wakeCondition: "changes-requested" as const, + }; + assert.equal((yield* service.dispatch(command).pipe(Effect.result))._tag, "Failure"); + assert.equal(reads, 0); + const restored = yield* service + .dispatch({ + ...command, + commandId: CommandId.make(`${threadId}:restore`), + resumeFrom: { + requestId: command.commandId, + url: command.url, + wakeCondition: command.wakeCondition, + startedAt: "2026-10-01T12:00:00Z", + nextCheckAt: "2026-10-01T12:00:00Z", + status: "watching", + failures: 0, + baseline: { latestAt: null, ids: [] }, + }, + }) + .pipe(Effect.result); + assert.equal(restored._tag, "Failure"); + assert.isNull((yield* orchestrator.getThreadShell(threadId))!.githubReplySnooze ?? null); + }), + ); + it.effect.each([ + "success", + "changes-requested", + "failure", + "discussion", + "discussion-comment", + "timed", + "settle", + "wake", + "replace", + "message", + "archive", + "delete", + "provider-completion", + "resume-reset", + "cancel-recovery", + "rename-with-recovery", + ] as const)( + "captures the baseline before committing reply snooze and gives later user actions precedence: %s", + (scenario) => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projectId = ProjectId.make(`github-setup:${scenario}:project`); + const threadId = ThreadId.make(`github-setup:${scenario}:thread`); + yield* projects.create({ + commandId: CommandId.make(`${projectId}:create`), + projectId, + title: "Setup", + workspaceRoot: `/tmp/${projectId}`, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId, + title: "Setup", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + if (scenario === "changes-requested") { + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make(`${threadId}:link`), + threadId, + host: "github.com", + repository: "team/repo", + number: 1, + url: "https://github.com/team/repo/pull/1", + source: "manual", + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make(`${threadId}:sync`), + threadId, + host: "github.com", + repository: "team/repo", + number: 1, + snapshot: { + state: "open", + title: "Open PR", + headBranch: "work", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: DateTime.formatIso(yield* DateTime.now), + }, + stack: null, + }); + } + if (scenario === "provider-completion") { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:original-work`), + messageId: MessageId.make(`${threadId}:original-work`), + threadId, + text: "Original work", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const sink = yield* EventSink.EventSinkV2; + const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make(`${threadId}:running`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "running", startedAt: now }, + }, + ], + }); + } + let recovery: { runId: RunId; resetAt: string; autoResume: boolean } | undefined; + if ( + scenario === "resume-reset" || + scenario === "cancel-recovery" || + scenario === "rename-with-recovery" + ) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:limited-work`), + messageId: MessageId.make(`${threadId}:limited-work`), + threadId, + text: "Original work", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + const now = yield* DateTime.now; + recovery = { + runId: run.id, + resetAt: DateTime.formatIso(DateTime.add(now, { minutes: 1 })), + autoResume: true, + }; + const sink = yield* EventSink.EventSinkV2; + yield* sink.write({ + events: [ + { + id: EventId.make(`${threadId}:limited`), + type: "run.updated", + threadId, + occurredAt: now, + payload: { ...run, status: "failed", completedAt: now }, + }, + { + id: EventId.make(`${threadId}:limit-error`), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:limit-error`), + type: "error", + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Usage limit reached", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "usage_limit", + message: "Plan limit reached.", + code: "usageLimitExceeded", + retryable: null, + resetAt: recovery.resetAt, + }, + }, + }, + ], + }); + if (scenario !== "resume-reset") + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`${threadId}:existing-recovery`), + threadId, + limitRecovery: recovery, + }); + } + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + let reads = 0; + const baseline = { latestAt: "2026-10-01T12:00:00Z", ids: ["existing"] }; + const freshBaseline = { latestAt: "2026-10-01T13:00:00Z", ids: ["new-reply"] }; + const service = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide( + ThreadManagementService.layer.pipe( + Layer.provide( + Layer.succeed(GitHubReplyReader.GitHubReplyReader, { + read: (input) => + Effect.gen(function* () { + reads++; + assert.isTrue(input.captureBaseline); + assert.equal( + input.watch.wakeCondition, + scenario === "changes-requested" ? "changes-requested" : undefined, + ); + assert.equal(input.cwd, `/tmp/${projectId}`); + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + if (scenario === "failure") + return yield* new GitHubReplyReader.GitHubReplyReadError({ + reason: "unavailable", + }); + return { + viewer: "me", + baseline: reads === 1 ? baseline : freshBaseline, + reply: null, + }; + }), + }), + ), + Layer.provide(CommandReceiptStore.layerFromApplicationReceipts), + Layer.provide(ProjectStore.layer), + ), + ), + ); + const command = { + type: "thread.github-reply.snooze" as const, + commandId: CommandId.make(`${threadId}:snooze`), + threadId, + ...(scenario === "changes-requested" + ? { wakeCondition: "changes-requested" as const } + : {}), + url: + scenario === "discussion" + ? "https://github.com/team/repo/discussions/1" + : scenario === "discussion-comment" + ? "https://github.com/team/repo/discussions/1#discussioncomment-10" + : "https://github.com/team/repo/pull/1", + }; + const fiber = yield* service.dispatch(command).pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(entered); + assert.isNull( + (yield* orchestrator.getThreadProjection(threadId)).thread.githubReplySnooze ?? null, + ); + switch (scenario) { + case "resume-reset": + case "cancel-recovery": + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`${threadId}:later-recovery`), + threadId, + limitRecovery: scenario === "cancel-recovery" ? null : recovery!, + }); + break; + case "rename-with-recovery": + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`${threadId}:rename`), + threadId, + title: "Renamed while fetching baseline", + }); + break; + case "timed": + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(`${threadId}:later-snooze`), + threadId, + snoozedUntil: "2099-01-01T00:00:00Z", + }); + break; + case "settle": + case "archive": + case "delete": + yield* orchestrator.dispatch({ + type: + scenario === "settle" + ? "thread.settle" + : scenario === "archive" + ? "thread.archive" + : "thread.delete", + commandId: CommandId.make(`${threadId}:later-action`), + threadId, + }); + break; + case "wake": { + const before = (yield* orchestrator.getThreadProjection(threadId)).thread; + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make(`${threadId}:later-wake`), + threadId, + reason: "user", + }); + assert.equal( + DateTime.toEpochMillis( + (yield* orchestrator.getThreadProjection(threadId)).thread.updatedAt, + ), + DateTime.toEpochMillis(before.updatedAt), + ); + break; + } + case "replace": + yield* orchestrator.dispatch({ + type: "thread.github-reply.snooze", + commandId: CommandId.make(`${threadId}:later-reply-snooze`), + threadId, + url: "https://github.com/team/repo/discussions/2", + }); + break; + case "message": + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:later-message`), + messageId: MessageId.make(`${threadId}:later-message`), + threadId, + text: "New work", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + break; + case "provider-completion": { + const sink = yield* EventSink.EventSinkV2; + const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make(`${threadId}:completed`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "completed", completedAt: now }, + }, + ], + }); + break; + } + } + const latest = yield* orchestrator.getThreadProjection(threadId); + yield* Deferred.succeed(release, undefined); + const result = yield* Fiber.join(fiber); + if ( + scenario !== "success" && + scenario !== "changes-requested" && + scenario !== "discussion" && + scenario !== "discussion-comment" && + scenario !== "rename-with-recovery" && + scenario !== "provider-completion" + ) { + assert.equal(result._tag, "Failure"); + const final = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(final.thread, latest.thread); + assert.deepEqual(final.runs, latest.runs); + return; + } + assert.equal(result._tag, "Success"); + const accepted = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(accepted.runs, latest.runs); + const watch = accepted.thread.githubReplySnooze!; + assert.deepEqual(watch.baseline, baseline); + assert.equal(watch.wakeCondition, command.wakeCondition); + const restarted = yield* ProjectionStore.ProjectionStoreV2.pipe( + Effect.provide(ProjectionStore.layer), + ); + assert.deepEqual( + (yield* restarted.getThread(threadId)).githubReplySnooze?.baseline, + baseline, + ); + yield* orchestrator.dispatch({ + type: "thread.github-reply.sync", + commandId: CommandId.make(`${threadId}:reply`), + threadId, + requestId: command.commandId, + watch: null, + notice: { + type: "reply", + receivedAt: DateTime.formatIso(yield* DateTime.now), + url: scenario.startsWith("discussion") + ? "https://github.com/team/repo/discussions/1#discussioncomment-20" + : "https://github.com/team/repo/pull/1#issuecomment-20", + text: "Feedback", + }, + }); + // Retrying an acknowledged command after wake must replay its receipt without a new API read. + yield* service.dispatch(command); + assert.equal(reads, 1); + assert.isNull((yield* restarted.getThread(threadId)).githubReplySnooze); + const notice = (yield* restarted.getThread(threadId)).githubReplyNotice!; + assert.equal(notice.conversationUrl, command.url); + assert.equal(notice.wakeCondition, command.wakeCondition); + assert.notEqual(notice.url, notice.conversationUrl); + yield* service.dispatch({ + type: "thread.github-reply.snooze", + commandId: CommandId.make(`${threadId}:snooze-again`), + threadId, + url: notice.conversationUrl!, + wakeCondition: notice.wakeCondition, + }); + assert.equal(reads, 2); + const resnoozed = yield* restarted.getThread(threadId); + assert.equal(resnoozed.githubReplySnooze?.url, command.url); + assert.deepEqual(resnoozed.githubReplySnooze?.baseline, freshBaseline); + assert.isNull(resnoozed.githubReplyNotice); + assert.equal(resnoozed.githubReplySnooze?.wakeCondition, command.wakeCondition); + }), + ); + it.effect.each( + ( + [ + "wake", + "cancel", + "replace", + "undo", + "archive-undo", + "timed", + "settle", + "archive", + "failure", + "retry", + "rate-limit", + ] as const + ).flatMap((scenario) => + ([undefined, "changes-requested"] as const).map((wakeCondition) => ({ + scenario, + wakeCondition, + })), + ), + )("persists $scenario with $wakeCondition", ({ scenario, wakeCondition }) => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const workerEffects = yield* EffectWorker.OrchestrationEffectWorkerV2; + const projectId = ProjectId.make( + `github-reply-${scenario}-${wakeCondition ?? "any"}-project`, + ); + const threadId = ThreadId.make(`github-reply-${scenario}-${wakeCondition ?? "any"}-thread`); + yield* projects.create({ + commandId: CommandId.make(`${projectId}:create`), + projectId, + title: "Reply snooze", + workspaceRoot: `/tmp/${projectId}`, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Reply snooze", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + if (wakeCondition === "changes-requested") { + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make(`${threadId}:link`), + threadId, + host: "github.com", + repository: "team/repo", + number: 1, + url: "https://github.com/team/repo/pull/1", + source: "manual", + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make(`${threadId}:sync`), + threadId, + host: "github.com", + repository: "team/repo", + number: 1, + snapshot: { + state: "open", + title: "Open PR", + headBranch: "work", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: DateTime.formatIso(yield* DateTime.now), + }, + stack: null, + }); + } + yield* workerEffects.drain(); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`${threadId}:settle-before`), + threadId, + }); + yield* orchestrator.dispatch({ + type: "thread.github-reply.snooze", + commandId: CommandId.make(`${threadId}:snooze`), + threadId, + url: "https://github.com/team/repo/pull/1", + resumeFrom: { + wakeCondition, + requestId: CommandId.make(`${threadId}:snooze`), + url: "https://github.com/team/repo/pull/1", + startedAt: DateTime.formatIso(yield* DateTime.now), + nextCheckAt: DateTime.formatIso(yield* DateTime.now), + status: "pending", + failures: 0, + viewer: "me", + baseline: { latestAt: "2026-10-01T12:00:00Z", ids: ["existing"] }, + }, + }); + const saved = yield* projections.getThread(threadId); + assert.isNotNull(saved.githubReplySnooze); + assert.equal(saved.githubReplySnooze?.wakeCondition, wakeCondition); + assert.isNull(saved.snoozedUntil); + assert.equal(saved.settledOverride, "active"); + const shell = (yield* orchestrator.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + assert.deepEqual(shell.githubReplySnooze, saved.githubReplySnooze); + // A fresh store and worker recover the subscription solely from committed SQLite state. + const restarted = yield* ProjectionStore.ProjectionStoreV2.pipe( + Effect.provide(ProjectionStore.layer), + ); + assert.deepEqual( + (yield* restarted.getThread(threadId)).githubReplySnooze, + saved.githubReplySnooze, + ); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const read = Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + if (scenario === "failure") + return yield* new GitHubReplyReader.GitHubReplyReadError({ + reason: "comment-not-found", + }); + if (scenario === "retry") + return yield* new GitHubReplyReader.GitHubReplyReadError({ reason: "unavailable" }); + if (scenario === "rate-limit") + return yield* new GitHubReplyReader.GitHubReplyReadError({ + reason: "rate-limited", + retryAt: 9_999_999_999_999, + }); + return { + viewer: "me", + baseline: { latestAt: null, ids: [] }, + reply: { + url: "https://github.com/team/repo/pull/1#issuecomment-2", + text: "Useful feedback", + author: "reviewer", + }, + }; + }); + const runSweep = Effect.gen(function* () { + const worker = yield* GitHubReplySnoozeWorker.GitHubReplySnoozeWorker; + yield* worker.sweep; + }).pipe( + Effect.provide(GitHubReplySnoozeWorker.layer), + Effect.provideService(GitHubReplyReader.GitHubReplyReader, { + read: ({ watch }) => { + assert.equal(watch.wakeCondition, wakeCondition); + return read; + }, + }), + ); + const fiber = yield* runSweep.pipe(Effect.forkChild); + yield* Deferred.await(entered); + if (scenario === "cancel") + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make(`${threadId}:cancel`), + threadId, + reason: "user", + }); + if (scenario === "replace") + yield* orchestrator.dispatch({ + type: "thread.github-reply.snooze", + commandId: CommandId.make(`${threadId}:replace`), + threadId, + url: "https://github.com/team/repo/discussions/2", + }); + if (scenario === "timed") + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(`${threadId}:timed`), + threadId, + snoozedUntil: "2099-10-01T00:00:00.000Z", + }); + if (scenario === "settle") + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`${threadId}:settle`), + threadId, + }); + if (scenario === "archive") + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make(`${threadId}:archive`), + threadId, + }); + if (scenario === "undo" || scenario === "archive-undo") { + yield* orchestrator.dispatch({ + type: scenario === "undo" ? "thread.settle" : "thread.archive", + commandId: CommandId.make(`${threadId}:settle`), + threadId, + }); + yield* TestClock.adjust("30 seconds"); + if (scenario === "archive-undo") + yield* orchestrator.dispatch({ + type: "thread.unarchive", + commandId: CommandId.make(`${threadId}:unarchive`), + threadId, + }); + yield* orchestrator.dispatch({ + type: "thread.github-reply.snooze", + commandId: CommandId.make(`${threadId}:restore`), + threadId, + url: saved.githubReplySnooze!.url, + resumeFrom: saved.githubReplySnooze!, + }); + } + if (scenario === "wake" || scenario === "failure") { + yield* TestClock.adjust("1 second"); + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make(`${threadId}:visit-during-poll`), + threadId, + visitedAt: DateTime.formatIso(yield* DateTime.now), + }); + yield* TestClock.adjust("1 second"); + } + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(fiber); + yield* workerEffects.drain(); + const final = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(final.runs, []); + assert.deepEqual(final.messages, []); + assert.deepEqual(final.providerTurns, []); + if (scenario === "wake" || scenario === "failure") { + assert.equal( + final.thread.githubReplyNotice?.receivedAt, + DateTime.formatIso(yield* DateTime.now), + ); + assert.isAbove( + Date.parse(final.thread.githubReplyNotice!.receivedAt), + DateTime.toEpochMillis(final.thread.lastVisitedAt!), + ); + assert.isNull(final.thread.githubReplySnooze); + assert.equal(final.thread.githubReplyNotice?.wakeCondition, wakeCondition); + assert.equal(final.thread.githubReplyNotice?.type, scenario === "wake" ? "reply" : "error"); + assert.equal(final.thread.settledOverride, "active"); + yield* runSweep; + assert.deepEqual( + (yield* projections.getThread(threadId)).githubReplyNotice, + final.thread.githubReplyNotice, + ); + } else if (scenario === "replace") { + assert.equal( + final.thread.githubReplySnooze?.url, + "https://github.com/team/repo/discussions/2", + ); + assert.isNull(final.thread.githubReplyNotice); + } else if (scenario === "undo" || scenario === "archive-undo") { + assert.equal(final.thread.githubReplySnooze?.wakeCondition, wakeCondition); + assert.deepEqual( + final.thread.githubReplySnooze?.baseline, + saved.githubReplySnooze?.baseline, + ); + assert.equal(final.thread.githubReplySnooze?.startedAt, saved.githubReplySnooze?.startedAt); + assert.notEqual( + final.thread.githubReplySnooze?.requestId, + saved.githubReplySnooze?.requestId, + ); + assert.isNull(final.thread.githubReplyNotice); + yield* runSweep; + assert.equal((yield* projections.getThread(threadId)).githubReplyNotice?.type, "reply"); + } else if (scenario === "retry") { + assert.equal(final.thread.githubReplySnooze?.status, "retrying"); + assert.equal(final.thread.githubReplySnooze?.failures, 1); + yield* runSweep; + assert.equal((yield* projections.getThread(threadId)).githubReplySnooze?.failures, 1); + yield* TestClock.adjust("2 minutes"); + yield* runSweep; + assert.equal((yield* projections.getThread(threadId)).githubReplySnooze?.failures, 2); + yield* TestClock.adjust("4 minutes"); + yield* runSweep; + assert.equal((yield* projections.getThread(threadId)).githubReplyNotice?.type, "error"); + } else if (scenario === "rate-limit") { + assert.equal(final.thread.githubReplySnooze?.status, "rate-limited"); + assert.equal(final.thread.githubReplySnooze?.failures, 0); + } else { + assert.isNull(final.thread.githubReplySnooze); + assert.isNull(final.thread.githubReplyNotice); + } + }).pipe(Effect.provide(Layer.mergeAll(ProjectionStore.layer, ProjectStore.layer))), + ); +}); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 5d736f1564beb..0d7cdae829b2a 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -1,3 +1,5 @@ +import * as GitHubReplySnoozeWorker from "./GitHubReplySnoozeWorker.ts"; +import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts"; import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as Layer from "effect/Layer"; @@ -240,7 +242,14 @@ const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe( ); const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImporter.pipe( - Layer.provide(Layer.merge(layerOrchestratorProvided, layerLegacyV1ThreadImporterProvided)), + Layer.provide( + Layer.mergeAll( + layerOrchestratorProvided, + layerLegacyV1ThreadImporterProvided, + layerCommandReceiptStoreProvided, + ProjectStore.layer, + ), + ), ); export const layerProjectSetupScriptRunner = ProjectSetupScriptRunner.layer.pipe( Layer.provide(layerProjectService), @@ -349,10 +358,23 @@ export const layerProduction = Layer.mergeAll( layerThreadLifecycleProvided, layerScheduledTaskProvided, layerSecretRequestsProvided, + GitHubReplySnoozeWorker.layerScheduled.pipe( + Layer.provide( + GitHubReplySnoozeWorker.layer.pipe( + Layer.provide( + Layer.mergeAll(ProjectionStore.layer, ProjectStore.layer, layerThreadManagementProvided), + ), + ), + ), + ), UsageLimitRecoveryWorker.layer.pipe( Layer.provide(Layer.mergeAll(ProjectionStore.layer, layerThreadManagementProvided)), ), layerProviderContinuationWorkerProvided, layerAgentSessionImporterProvided, EffectOutbox.layerPruneWorker.pipe(Layer.provide(EffectOutbox.layer)), -).pipe(Layer.provide(Scheduler.layer), Layer.provideMerge(layerEventInfrastructure)); +).pipe( + Layer.provide(GitHubReplyReader.layer), + Layer.provide(Scheduler.layer), + Layer.provideMerge(layerEventInfrastructure), +); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 3d8ef589050e6..0cc0a7ef91e2e 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -140,6 +140,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray, + queries: Array[0]>, +) { + let index = 0; + return GitHubDiscussions.layer.pipe( + Layer.provide( + Layer.mock(GitHubApi.GitHubApi)({ + graphql: (input) => + Effect.sync(() => { + queries.push(input); + assert.isBelow(index, responses.length, "unexpected GitHub request"); + return encode(responses[index++]); + }), + }), + ), + ); +} + +it.effect("links with a title-only read without fetching discussion comments", () => { + const queries: Array[0]> = []; + return Effect.gen(function* () { + const reader = yield* GitHubDiscussions.GitHubDiscussions; + assert.deepEqual(yield* reader.summary({ cwd: "/repo", url }), { title: "A title" }); + assert.equal(queries.length, 1); + assert.notInclude(queries[0]!.query, "comments"); + }).pipe( + Effect.provide( + layer([{ data: { repository: { discussion: { title: "A title" } } } }], queries), + ), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubDiscussions.ts b/apps/server/src/sourceControl/GitHubDiscussions.ts new file mode 100644 index 0000000000000..e2cda1f15b76b --- /dev/null +++ b/apps/server/src/sourceControl/GitHubDiscussions.ts @@ -0,0 +1,74 @@ +import { + DiscussionOperationError, + TrimmedNonEmptyString, + parseGitHubConversationUrl, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as GitHubApi from "./GitHubApi.ts"; + +const SummaryResponse = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + discussion: Schema.NullOr(Schema.Struct({ title: TrimmedNonEmptyString })), + }), + ), + }), +}); +const decodeSummary = Schema.decodeUnknownEffect(Schema.fromJsonString(SummaryResponse)); + +export class GitHubDiscussions extends Context.Service< + GitHubDiscussions, + { + readonly summary: (input: { + readonly cwd: string; + readonly url: string; + }) => Effect.Effect<{ readonly title: string }, DiscussionOperationError>; + } +>()("t3/sourceControl/GitHubDiscussions") {} + +const make = Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const summary = Effect.fn("GitHubDiscussions.summary")(function* ({ + url, + }: { + readonly cwd: string; + readonly url: string; + }) { + const target = parseGitHubConversationUrl(url); + if (target?.kind !== "discussion") + return yield* new DiscussionOperationError({ + message: "Use a github.com repository discussion URL.", + }); + const output = yield* api + .graphql({ + operation: "discussionSummary", + allowReserve: false, + host: "github.com", + query: + "query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { discussion(number: $number) { title } } }", + variables: { owner: target.owner, name: target.repository, number: target.number }, + }) + .pipe(Effect.mapError((cause) => new DiscussionOperationError({ message: cause.message }))); + const result = yield* decodeSummary(output).pipe( + Effect.mapError( + () => + new DiscussionOperationError({ + message: "GitHub returned an invalid discussion response.", + }), + ), + ); + const discussion = result.data.repository?.discussion; + if (!discussion) + return yield* new DiscussionOperationError({ + message: "Discussion not found. Check GitHub sign-in and repository access.", + }); + return discussion; + }); + return GitHubDiscussions.of({ summary }); +}); + +export const layer = Layer.effect(GitHubDiscussions, make); diff --git a/apps/server/src/sourceControl/GitHubReplyReader.test.ts b/apps/server/src/sourceControl/GitHubReplyReader.test.ts new file mode 100644 index 0000000000000..47bff64ab3a13 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubReplyReader.test.ts @@ -0,0 +1,631 @@ +import { assert, it } from "@effect/vitest"; +import { CommandId, type GitHubReplySnooze } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Redacted from "effect/Redacted"; +import * as Ref from "effect/Ref"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubReplyReader from "./GitHubReplyReader.ts"; + +const watch: GitHubReplySnooze = { + requestId: CommandId.make("watch"), + url: "https://github.com/team/repo/pull/1", + startedAt: "2026-10-01T12:00:00.000Z", + nextCheckAt: "2026-10-01T12:00:00.000Z", + status: "pending", + failures: 0, + baseline: { latestAt: "2026-10-01T11:00:00Z", ids: ["existing", "root-1", "root-2", "root"] }, +}; +const old = "2026-10-01T11:00:00Z"; +const recent = "2026-10-01T12:01:00Z"; +const comment = (id: string, author = "someone", createdAt = recent, databaseId = 1) => ({ + id, + author: { login: author }, + createdAt, + databaseId, +}); +const page = (nodes: ReadonlyArray, cursor: string | null = null) => ({ + nodes, + pageInfo: { hasPreviousPage: cursor !== null, startCursor: cursor }, +}); +const response = (data: object) => ({ data: { viewer: { login: "me" }, ...data } }); +const pr = (nodes: ReadonlyArray>, cursor: string | null = null) => + response({ repository: { pullRequest: { comments: page(nodes, cursor) } } }); +const emptyReviews = response({ repository: { pullRequest: { reviews: page([]) } } }); +const emptyThreads = response({ + repository: { + pullRequest: { + reviewThreads: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } }, + }, + }, +}); +const body = response({ + node: { + url: "https://github.com/team/repo/pull/1#issuecomment-2", + bodyText: "Please consider this.", + author: { login: "someone" }, + }, +}); +const encode = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +function layer( + responses: ReadonlyArray, + queries: string[] = [], + fingerprint: Effect.Effect = Effect.succeed("test-credential"), +) { + let index = 0; + return GitHubReplyReader.layer.pipe( + Layer.provide( + Layer.mock(GitHubApi.GitHubApi)({ + credential: () => + fingerprint.pipe( + Effect.map((fingerprint) => ({ token: Redacted.make("test-token"), fingerprint })), + ), + graphql: (input) => + Effect.sync(() => { + queries.push(input.query); + assert.isBelow(index, responses.length, "unexpected GitHub request"); + return encode(responses[index++]); + }), + }), + ), + ); +} + +it.effect("reads new feedback without filtering out merged or closed PRs", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const result = yield* reader.read({ cwd: "/repo", watch }); + assert.equal(result.reply?.text, "Please consider this."); + assert.equal(result.viewer, "me"); + }).pipe(Effect.provide(layer([pr([comment("existing", "someone", old), comment("new")]), body]))), +); + +it.effect("ignores existing comments, own comments and CI-only updates", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + assert.isNull((yield* reader.read({ cwd: "/repo", watch })).reply); + }).pipe( + Effect.provide( + layer([ + pr([comment("existing", "someone", old), comment("own", "me")]), + emptyReviews, + emptyThreads, + ]), + ), + ), +); + +it.effect("pages past newer self comments to another person's response", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + assert.isNotNull((yield* reader.read({ cwd: "/repo", watch })).reply); + }).pipe(Effect.provide(layer([pr([comment("own", "me")], "older"), pr([comment("new")]), body]))), +); + +it.effect("finds replies on an old discussion comment beyond the first page", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + assert.isNotNull( + (yield* reader.read({ + cwd: "/repo", + watch: { ...watch, url: "https://github.com/team/repo/discussions/1" }, + })).reply, + ); + }).pipe( + Effect.provide( + layer([ + response({ + repository: { + discussion: { + comments: page([{ ...comment("root-1", "me", old), replies: page([]) }], "older"), + }, + }, + }), + response({ + repository: { + discussion: { + comments: page([ + { ...comment("root-2", "me", old), replies: page([comment("own", "me")], "more") }, + ]), + }, + }, + }), + response({ node: { replies: page([comment("own", "me")], "older-replies") } }), + response({ node: { replies: page([comment("reply")]) } }), + body, + ]), + ), + ), +); + +it.effect("honors a discussion comment anchor and persists the resolved parent", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const result = yield* reader.read({ + cwd: "/repo", + watch: { ...watch, url: "https://github.com/team/repo/discussions/1#discussioncomment-5" }, + }); + assert.equal(result.discussionCommentId, "root"); + assert.isNull(result.reply); + }).pipe( + Effect.provide( + layer([ + response({ + repository: { + discussion: { + comments: page([{ ...comment("root", "me", old, 5), replies: page([]) }]), + }, + }, + }), + response({ node: { replies: page([]) } }), + ]), + ), + ), +); + +it.effect("detects a review submitted after snoozing even if its draft is older", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + assert.isNotNull((yield* reader.read({ cwd: "/repo", watch })).reply); + }).pipe( + Effect.provide( + layer([ + pr([]), + response({ + repository: { + pullRequest: { + reviews: page([ + { + ...comment("review", "someone", old), + submittedAt: recent, + state: "CHANGES_REQUESTED", + }, + ]), + }, + }, + }), + body, + ]), + ), + ), +); + +it.effect("detects inline review replies and pages past own replies", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + assert.isNotNull((yield* reader.read({ cwd: "/repo", watch })).reply); + }).pipe( + Effect.provide( + layer([ + pr([]), + emptyReviews, + response({ + repository: { + pullRequest: { + reviewThreads: { + nodes: [{ id: "review-thread", comments: page([comment("own", "me")]) }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }), + response({ node: { comments: page([comment("other"), comment("own", "me")]) } }), + body, + ]), + ), + ), +); + +it.effect("reports missing conversations instead of treating them as successfully watched", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const result = yield* reader.read({ cwd: "/repo", watch }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }).pipe(Effect.provide(layer([response({ repository: { pullRequest: null } })]))), +); + +it.effect("shares cached reads for threads watching the same conversation", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + yield* reader.read({ cwd: "/repo", watch }); + yield* reader.read({ cwd: "/repo", watch: { ...watch, requestId: CommandId.make("other") } }); + }).pipe(Effect.provide(layer([pr([]), emptyReviews, emptyThreads]))), +); + +it.effect.each(["2026-10-01T12:00:00.100Z", "2026-10-01T14:00:00.100Z"])( + "tracks same-second reply IDs independently of local start time %s after restart", + (startedAt) => + Effect.gen(function* () { + const boundary = "2026-10-01T12:00:00Z"; + const setup = yield* Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + return yield* reader.read({ + cwd: "/repo", + watch: { ...watch, startedAt }, + captureBaseline: true, + }); + }).pipe( + Effect.provide( + layer([ + pr([comment("existing-2", "someone", boundary)], "older"), + pr([comment("existing-1", "someone", boundary), comment("ancient", "someone", old)]), + emptyReviews, + emptyThreads, + ]), + ), + ); + assert.deepEqual(setup.baseline, { latestAt: boundary, ids: ["existing-2", "existing-1"] }); + assert.isNull(setup.reply); + // Recreate the reader from only the persisted watch. A known ID in the newest page + // must not stop pagination before another response in the same second. + const restored = { ...watch, startedAt, viewer: setup.viewer, baseline: setup.baseline }; + const reply = yield* Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + return yield* reader.read({ cwd: "/repo", watch: restored }); + }).pipe( + Effect.provide( + layer([ + pr( + [comment("existing-2", "someone", boundary), comment("own", "me", boundary)], + "older", + ), + pr([comment("existing-1", "someone", boundary), comment("new", "someone", boundary)]), + body, + ]), + ), + ); + assert.isNotNull(reply.reply); + }), +); + +it.effect("ignores existing boundary IDs and new own comments", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const result = yield* reader.read({ + cwd: "/repo", + watch: { + ...watch, + baseline: { latestAt: recent, ids: ["existing"] }, + }, + }); + assert.isNull(result.reply); + }).pipe( + Effect.provide( + layer([pr([comment("existing"), comment("own", "me")]), emptyReviews, emptyThreads]), + ), + ), +); + +it.effect("captures fresh setup data even when a polling result is cached", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + yield* reader.read({ cwd: "/repo", watch }); + const setup = yield* reader.read({ cwd: "/repo", watch, captureBaseline: true }); + assert.deepEqual(setup.baseline, { latestAt: recent, ids: ["arrived-before-setup"] }); + }).pipe( + Effect.provide( + layer([ + pr([]), + emptyReviews, + emptyThreads, + pr([comment("arrived-before-setup")]), + emptyReviews, + emptyThreads, + ]), + ), + ), +); + +it.effect("uses review submission time and leaves pending drafts outside the baseline", () => + Effect.gen(function* () { + const setup = yield* Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + return yield* reader.read({ cwd: "/repo", watch, captureBaseline: true }); + }).pipe( + Effect.provide( + layer([ + pr([]), + response({ + repository: { + pullRequest: { + reviews: page([ + { + ...comment("submitted", "someone", old), + submittedAt: recent, + state: "COMMENTED", + }, + { ...comment("draft", "someone", old), submittedAt: null, state: "PENDING" }, + ]), + }, + }, + }), + emptyThreads, + ]), + ), + ); + assert.deepEqual(setup.baseline, { latestAt: recent, ids: ["submitted"] }); + const result = yield* Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + return yield* reader.read({ cwd: "/repo", watch: { ...watch, baseline: setup.baseline } }); + }).pipe( + Effect.provide( + layer([ + pr([]), + response({ + repository: { + pullRequest: { + reviews: page( + [ + { + ...comment("submitted", "someone", old), + submittedAt: recent, + state: "COMMENTED", + }, + ], + "older-draft", + ), + }, + }, + }), + response({ + repository: { + pullRequest: { + reviews: page([ + { + ...comment("draft", "someone", old), + submittedAt: recent, + state: "CHANGES_REQUESTED", + }, + ]), + }, + }, + }), + body, + ]), + ), + ); + assert.isNotNull(result.reply); + }), +); + +it.effect("fails visibly for a persisted watch without an observation baseline", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const { baseline: _baseline, ...legacyWatch } = watch; + const error = yield* reader.read({ cwd: "/repo", watch: legacyWatch }).pipe(Effect.flip); + assert.equal(error.reason, "baseline-missing"); + }).pipe(Effect.provide(layer([]))), +); + +it.effect("keeps the selected discussion parent and boundary reply IDs through setup", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const setup = yield* reader.read({ + cwd: "/repo", + captureBaseline: true, + watch: { + ...watch, + url: "https://github.com/team/repo/discussions/1#discussioncomment-5", + }, + }); + assert.equal(setup.discussionCommentId, "root"); + assert.deepEqual(setup.baseline, { latestAt: recent, ids: ["reply-2", "reply-1"] }); + assert.isNull(setup.reply); + }).pipe( + Effect.provide( + layer([ + response({ + repository: { + discussion: { + comments: page([ + { ...comment("root", "me", old, 5), replies: page([comment("reply-2")]) }, + ]), + }, + }, + }), + response({ node: { replies: page([comment("reply-2")], "older") } }), + response({ node: { replies: page([comment("old", "someone", old), comment("reply-1")]) } }), + ]), + ), + ), +); + +const changesWatch: GitHubReplySnooze = { ...watch, wakeCondition: "changes-requested" }; +const review = ( + id: string, + state = "CHANGES_REQUESTED", + author = "someone", + submittedAt: string | null = recent, + createdAt = old, +) => ({ + ...comment(id, author, createdAt), + state, + submittedAt, +}); +const reviews = (nodes: ReadonlyArray>, cursor: string | null = null) => + response({ repository: { pullRequest: { reviews: page(nodes, cursor) } } }); +const emptyReviewBody = response({ + node: { + url: "https://github.com/team/repo/pull/1#pullrequestreview-2", + bodyText: "", + author: { login: "someone" }, + }, +}); + +it.effect( + "changes-requested snooze reads only reviews and ignores approvals, comments, own and pending reviews", + () => { + const queries: string[] = []; + return Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const result = yield* reader.read({ cwd: "/repo", watch: changesWatch }); + assert.isNull(result.reply); + assert.equal(queries.length, 1); + assert.include(queries[0]!, "reviews(last:"); + assert.notInclude(queries[0]!, "comments("); + assert.notInclude(queries[0]!, "reviewThreads("); + }).pipe( + Effect.provide( + layer( + [ + reviews([ + review("existing", "CHANGES_REQUESTED", "someone", old), + review("approval", "APPROVED"), + review("comment", "COMMENTED"), + review("self", "CHANGES_REQUESTED", "me"), + review("pending", "PENDING", "someone", null), + review("unsubmitted", "CHANGES_REQUESTED", "someone", null), + ]), + ], + queries, + ), + ), + ); + }, +); + +it.effect( + "changes-requested snooze wakes for an empty review submitted from an older draft beyond the first page", + () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const result = yield* reader.read({ cwd: "/repo", watch: changesWatch }); + assert.deepEqual(result.reply, { + url: "https://github.com/team/repo/pull/1#pullrequestreview-2", + text: "", + author: "someone", + }); + }).pipe( + Effect.provide( + layer([ + reviews( + [ + review("approval", "APPROVED"), + review("existing", "CHANGES_REQUESTED", "someone", old), + ], + "older", + ), + reviews([review("draft-submitted-later")]), + emptyReviewBody, + ]), + ), + ), +); + +it.effect( + "captures all existing requested-changes reviews and preserves same-second novelty across restart", + () => + Effect.gen(function* () { + const setup = yield* Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + return yield* reader.read({ cwd: "/repo", watch: changesWatch, captureBaseline: true }); + }).pipe( + Effect.provide( + layer([ + reviews([review("existing-2"), review("draft", "PENDING", "someone", null)], "older"), + reviews([review("existing-1"), review("ancient", "CHANGES_REQUESTED", "someone", old)]), + ]), + ), + ); + assert.isNull(setup.reply); + assert.deepEqual(setup.baseline, { latestAt: recent, ids: ["existing-2", "existing-1"] }); + const restored = { ...changesWatch, viewer: setup.viewer, baseline: setup.baseline }; + const unchanged = yield* Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + return yield* reader.read({ cwd: "/repo", watch: restored }); + }).pipe(Effect.provide(layer([reviews([review("existing-1"), review("existing-2")])]))); + assert.isNull(unchanged.reply); + const result = yield* Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + return yield* reader.read({ cwd: "/repo", watch: restored }); + }).pipe( + Effect.provide( + layer([ + reviews([review("existing-2")], "older"), + reviews([review("existing-1"), review("draft")]), + emptyReviewBody, + ]), + ), + ); + assert.isNotNull(result.reply); + }), +); + +it.effect("rejects a discussion for changes-requested snooze without reading GitHub", () => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const error = yield* reader + .read({ + cwd: "/repo", + watch: { ...changesWatch, url: "https://github.com/team/repo/discussions/1" }, + captureBaseline: true, + }) + .pipe(Effect.flip); + assert.equal(error.reason, "unavailable"); + }).pipe(Effect.provide(layer([]))), +); + +it.effect("does not reuse another credential's cached viewer", () => + Effect.gen(function* () { + const fingerprint = yield* Ref.make("first-account"); + const queries: string[] = []; + const reader = yield* GitHubReplyReader.GitHubReplyReader.pipe( + Effect.provide( + layer( + [ + pr([]), + emptyReviews, + emptyThreads, + { data: { ...pr([]).data, viewer: { login: "other-account" } } }, + ], + queries, + Ref.get(fingerprint), + ), + ), + ); + const saved = { ...watch, viewer: "me" }; + assert.isNull((yield* reader.read({ cwd: "/repo", watch: saved })).reply); + yield* Ref.set(fingerprint, "second-account"); + const error = yield* reader.read({ cwd: "/repo", watch: saved }).pipe(Effect.flip); + assert.equal(error.reason, "account-changed"); + assert.lengthOf(queries, 4); + }), +); + +it.effect.each([ + new GitHubApi.GitHubApiRateLimitError({ + host: "github.com", + operation: "readGitHubReplies", + retryAt: 1_800_000_000_000, + }), + new SourceControlRateLimit.SourceControlRateLimitPausedError({ + provider: "github", + host: "github.com", + retryAt: 1_800_000_000_000, + }), +])("preserves GitHub quota pauses: %s", (cause) => + Effect.gen(function* () { + const reader = yield* GitHubReplyReader.GitHubReplyReader; + const error = yield* reader.read({ cwd: "/repo", watch }).pipe(Effect.flip); + assert.equal(error.reason, "rate-limited"); + assert.equal(error.retryAt, cause.retryAt); + }).pipe( + Effect.provide( + GitHubReplyReader.layer.pipe( + Layer.provide( + Layer.mock(GitHubApi.GitHubApi)({ + credential: () => + Effect.succeed({ + token: Redacted.make("test-token"), + fingerprint: "test-credential", + }), + graphql: () => Effect.fail(cause), + }), + ), + ), + ), + ), +); diff --git a/apps/server/src/sourceControl/GitHubReplyReader.ts b/apps/server/src/sourceControl/GitHubReplyReader.ts new file mode 100644 index 0000000000000..6d0144704fc39 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubReplyReader.ts @@ -0,0 +1,421 @@ +import { parseGitHubConversationUrl, type GitHubReplySnooze } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as GitHubApi from "./GitHubApi.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +export class GitHubReplyReadError extends Schema.TaggedError()( + "GitHubReplyReadError", + { + reason: Schema.Literals([ + "unavailable", + "account-changed", + "comment-not-found", + "rate-limited", + "baseline-missing", + ]), + retryAt: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message() { + switch (this.reason) { + case "rate-limited": + return "GitHub checks are paused until the API rate limit resets."; + case "account-changed": + return "The GitHub account changed. Snooze again using the intended account."; + case "comment-not-found": + return "The selected discussion comment is no longer available."; + case "baseline-missing": + return "This saved GitHub snooze needs to be renewed. Snooze again to continue checking replies."; + case "unavailable": + return "GitHub replies could not be checked. Check GitHub sign-in and repository access, then snooze again."; + } + } +} + +const Author = Schema.NullOr(Schema.Struct({ login: Schema.String })); +const Comment = Schema.Struct({ + id: Schema.String, + databaseId: Schema.optional(Schema.NullOr(Schema.Number)), + createdAt: Schema.String, + author: Author, +}); +const PageInfo = Schema.Struct({ + hasPreviousPage: Schema.Boolean, + startCursor: Schema.NullOr(Schema.String), +}); +const Comments = Schema.Struct({ nodes: Schema.Array(Comment), pageInfo: PageInfo }); +const DiscussionComments = Schema.Struct({ + nodes: Schema.Array(Schema.Struct({ ...Comment.fields, replies: Comments })), + pageInfo: PageInfo, +}); +const Reviews = Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + ...Comment.fields, + submittedAt: Schema.NullOr(Schema.String), + state: Schema.String, + }), + ), + pageInfo: PageInfo, +}); +const ReviewThreads = Schema.Struct({ + nodes: Schema.Array(Schema.Struct({ id: Schema.String, comments: Comments })), + pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean, endCursor: Schema.NullOr(Schema.String) }), +}); +const commentFields = "id databaseId createdAt author { login }"; +const pageFields = "pageInfo { hasPreviousPage startCursor }"; +const Response = Schema.Struct({ + data: Schema.Struct({ + viewer: Schema.Struct({ login: Schema.String }), + repository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.optional( + Schema.NullOr( + Schema.Struct({ + comments: Schema.optional(Comments), + reviews: Schema.optional(Reviews), + reviewThreads: Schema.optional(ReviewThreads), + }), + ), + ), + discussion: Schema.optional( + Schema.NullOr(Schema.Struct({ comments: DiscussionComments })), + ), + }), + ), + ), + node: Schema.optional( + Schema.NullOr( + Schema.Struct({ + replies: Schema.optional(Comments), + comments: Schema.optional(Comments), + bodyText: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + author: Schema.optional(Author), + }), + ), + ), + }), +}); +const Reply = Schema.Struct({ url: Schema.String, text: Schema.String, author: Schema.String }); +type Reply = typeof Reply.Type; + +export class GitHubReplyReader extends Context.Service< + GitHubReplyReader, + { + readonly read: (input: { + readonly cwd: string; + readonly watch: GitHubReplySnooze; + readonly captureBaseline?: boolean; + }) => Effect.Effect< + { + readonly viewer: string; + readonly discussionCommentId?: string; + readonly reply: Reply | null; + readonly baseline: NonNullable; + }, + GitHubReplyReadError + >; + } +>()("t3/sourceControl/GitHubReplyReader") {} + +const Variables = Schema.Record( + Schema.String, + Schema.Union([Schema.String, Schema.Number, Schema.Null]), +); +const QueryKey = Schema.fromJsonString( + Schema.Tuple([Schema.String, Schema.String, Schema.String, Variables]), +); +const encodeKey = Schema.encodeSync(QueryKey); +const decodeKey = Schema.decodeUnknownEffect(QueryKey); +const decodeResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(Response)); + +const make = Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const mapApiError = (cause: GitHubApi.GitHubApiError) => { + const rateLimited = + cause._tag === "GitHubApiRateLimitError" || + cause._tag === "SourceControlRateLimitPausedError"; + return new GitHubReplyReadError({ + reason: rateLimited ? "rate-limited" : "unavailable", + ...(rateLimited && cause.retryAt !== undefined ? { retryAt: cause.retryAt } : {}), + cause, + }); + }; + const queryRaw = Effect.fn("GitHubReplyReader.query")(function* ( + _cwd: string, + document: string, + variables: Readonly>, + ) { + const result = yield* api + .graphql({ + host: "github.com", + operation: "readGitHubReplies", + query: document, + variables, + allowReserve: false, + }) + .pipe(Effect.mapError(mapApiError)); + return yield* decodeResponse(result).pipe( + Effect.mapError((cause) => new GitHubReplyReadError({ reason: "unavailable", cause })), + ); + }); + + const queries = yield* Cache.make({ + capacity: 256, + timeToLive: "30 seconds", + lookup: (key: string) => + decodeKey(key).pipe( + Effect.orDie, + Effect.flatMap(([, cwd, document, variables]) => queryRaw(cwd, document, variables)), + ), + }); + const query = ( + cwd: string, + document: string, + variables: Readonly>, + credentialFingerprint: string, + ) => Cache.get(queries, encodeKey([credentialFingerprint, cwd, document, variables])); + + const readConversation = Effect.fnUntraced(function* ( + { cwd, watch, captureBaseline = false }: Parameters[0], + credentialFingerprint: string, + ) { + const target = parseGitHubConversationUrl(watch.url); + const changesRequested = watch.wakeCondition === "changes-requested"; + if (!target || (changesRequested && target.kind !== "pull")) + return yield* new GitHubReplyReadError({ reason: "unavailable" }); + if (!captureBaseline && !watch.baseline) + return yield* new GitHubReplyReadError({ reason: "baseline-missing" }); + // Setup must see fresh data. Only acknowledge snooze after this observation completes. + // GitHub has no snapshot across connections; responses observed during setup are existing. + const readQuery = ( + cwd: string, + document: string, + variables: Readonly>, + ) => + captureBaseline + ? queryRaw(cwd, document, variables) + : query(cwd, document, variables, credentialFingerprint); + let baseline = (captureBaseline ? undefined : watch.baseline) ?? { latestAt: null, ids: [] }; + const existingIds = new Set(baseline.ids); + const observe = (comment: typeof Comment.Type) => { + if (!captureBaseline) return; + const timestamp = Date.parse(comment.createdAt); + const latest = baseline.latestAt === null ? -Infinity : Date.parse(baseline.latestAt); + if (timestamp > latest) baseline = { latestAt: comment.createdAt, ids: [comment.id] }; + else if (timestamp === latest) baseline = { ...baseline, ids: [...baseline.ids, comment.id] }; + }; + let viewer = watch.viewer; + const checkViewer = (login: string) => { + if (viewer !== undefined && viewer !== login) + return Effect.fail(new GitHubReplyReadError({ reason: "account-changed" })); + viewer = login; + return Effect.void; + }; + const isNew = (comment: typeof Comment.Type) => { + if (captureBaseline || baseline.latestAt === null) return true; + const timestamp = Date.parse(comment.createdAt); + const latest = Date.parse(baseline.latestAt); + return timestamp > latest || (timestamp === latest && !existingIds.has(comment.id)); + }; + const isBeforeBaseline = (comment: typeof Comment.Type) => + baseline.latestAt !== null && Date.parse(comment.createdAt) < Date.parse(baseline.latestAt); + const mayContainResponse = (comment: typeof Comment.Type) => + captureBaseline ? !isBeforeBaseline(comment) : isNew(comment); + const isResponse = (comment: typeof Comment.Type) => { + observe(comment); + return ( + !captureBaseline && + isNew(comment) && + comment.author !== null && + comment.author.login !== viewer + ); + }; + let responseId: string | undefined; + let discussionCommentId = watch.discussionCommentId; + + // Replies are paged independently: a newer self-reply must not hide another person's response. + const readReplies = Effect.fn("GitHubReplyReader.replies")(function* ( + id: string, + findAnchor?: number, + reviewThread = false, + ) { + let before: string | null = null; + while (true) { + const result: typeof Response.Type = yield* readQuery( + cwd, + `query($id: ID!, $before: String) { viewer { login } node(id: $id) { ... on ${reviewThread ? "PullRequestReviewThread" : "DiscussionComment"} { ${reviewThread ? "comments" : "replies"}(last: 100, before: $before) { nodes { ${commentFields} } ${pageFields} } } } }`, + { id, before }, + ); + yield* checkViewer(result.data.viewer.login); + const page: typeof Comments.Type | undefined = reviewThread + ? result.data.node?.comments + : result.data.node?.replies; + if (!page) return yield* new GitHubReplyReadError({ reason: "comment-not-found" }); + if (findAnchor !== undefined) { + if (page.nodes.some((comment) => comment.databaseId === findAnchor)) return true; + } else { + responseId = page.nodes.find(isResponse)?.id; + if (responseId || page.nodes.some(isBeforeBaseline)) return false; + } + if (!page.pageInfo.hasPreviousPage) return false; + if (!page.pageInfo.startCursor || page.pageInfo.startCursor === before) + return yield* new GitHubReplyReadError({ reason: "unavailable" }); + before = page.pageInfo.startCursor; + } + }); + + if (target.kind === "discussion" && discussionCommentId) { + yield* readReplies(discussionCommentId); + } else if (!changesRequested) { + let before: string | null = null; + while (true) { + const field = target.kind === "pull" ? "pullRequest" : "discussion"; + const replies = + target.kind === "pull" + ? "" + : `replies(last: 1) { nodes { ${commentFields} } ${pageFields} }`; + const result: typeof Response.Type = yield* readQuery( + cwd, + `query($owner: String!, $name: String!, $number: Int!, $before: String) { viewer { login } repository(owner: $owner, name: $name) { ${field}(number: $number) { comments(last: 100, before: $before) { nodes { ${commentFields} ${replies} } ${pageFields} } } } }`, + { owner: target.owner, name: target.repository, number: target.number, before }, + ); + yield* checkViewer(result.data.viewer.login); + if (target.kind === "pull") { + const page: typeof Comments.Type | undefined = + result.data.repository?.pullRequest?.comments; + if (!page) return yield* new GitHubReplyReadError({ reason: "unavailable" }); + responseId = page.nodes.find(isResponse)?.id; + if (responseId || page.nodes.some(isBeforeBaseline) || !page.pageInfo.hasPreviousPage) + break; + if (!page.pageInfo.startCursor || page.pageInfo.startCursor === before) + return yield* new GitHubReplyReadError({ reason: "unavailable" }); + before = page.pageInfo.startCursor; + } else { + const page: typeof DiscussionComments.Type | undefined = + result.data.repository?.discussion?.comments; + if (!page) return yield* new GitHubReplyReadError({ reason: "unavailable" }); + for (const comment of page.nodes) { + if (target.commentId !== null) { + if ( + comment.databaseId === target.commentId || + (yield* readReplies(comment.id, target.commentId)) + ) { + discussionCommentId = comment.id; + yield* readReplies(comment.id); + break; + } + } else { + if (isResponse(comment)) responseId = comment.id; + if (!responseId && comment.replies.nodes.some(mayContainResponse)) + yield* readReplies(comment.id); + if (responseId) break; + } + } + if (responseId || discussionCommentId || !page.pageInfo.hasPreviousPage) break; + if (!page.pageInfo.startCursor || page.pageInfo.startCursor === before) + return yield* new GitHubReplyReadError({ reason: "unavailable" }); + before = page.pageInfo.startCursor; + } + } + } + // reviews and inline threads are independent connections. PR state and CI changes never enter this reader. + if (target.kind === "pull" && !responseId) { + let before: string | null = null; + while (true) { + const result: typeof Response.Type = yield* readQuery( + cwd, + `query($owner: String!, $name: String!, $number: Int!, $before: String) { viewer { login } repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviews(last: 100, before: $before) { nodes { ${commentFields} submittedAt state } ${pageFields} } } } }`, + { owner: target.owner, name: target.repository, number: target.number, before }, + ); + yield* checkViewer(result.data.viewer.login); + const page: typeof Reviews.Type | undefined = result.data.repository?.pullRequest?.reviews; + if (!page) return yield* new GitHubReplyReadError({ reason: "unavailable" }); + responseId = page.nodes.find( + (review) => + (changesRequested + ? review.state === "CHANGES_REQUESTED" + : review.state !== "PENDING") && + review.submittedAt !== null && + isResponse({ ...review, createdAt: review.submittedAt }), + )?.id; + if (responseId || !page.pageInfo.hasPreviousPage) break; + // Reviews are ordered by creation, not submission. An old draft submitted today must still be read. + if (!page.pageInfo.startCursor || page.pageInfo.startCursor === before) + return yield* new GitHubReplyReadError({ reason: "unavailable" }); + before = page.pageInfo.startCursor; + } + let after: string | null = null; + while (true) { + if (changesRequested || responseId) break; + const result: typeof Response.Type = yield* readQuery( + cwd, + `query($owner: String!, $name: String!, $number: Int!, $after: String) { viewer { login } repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: 100, after: $after) { nodes { id comments(last: 1) { nodes { ${commentFields} } ${pageFields} } } pageInfo { hasNextPage endCursor } } } } }`, + { owner: target.owner, name: target.repository, number: target.number, after }, + ); + yield* checkViewer(result.data.viewer.login); + const page: typeof ReviewThreads.Type | undefined = + result.data.repository?.pullRequest?.reviewThreads; + if (!page) return yield* new GitHubReplyReadError({ reason: "unavailable" }); + for (const thread of page.nodes) { + if (thread.comments.nodes.some(mayContainResponse)) + yield* readReplies(thread.id, undefined, true); + if (responseId) break; + } + if (responseId || !page.pageInfo.hasNextPage) break; + if (!page.pageInfo.endCursor || page.pageInfo.endCursor === after) + return yield* new GitHubReplyReadError({ reason: "unavailable" }); + after = page.pageInfo.endCursor; + } + } + if (target.commentId !== null && !discussionCommentId) + return yield* new GitHubReplyReadError({ reason: "comment-not-found" }); + let reply: Reply | null = null; + if (responseId) { + const result: typeof Response.Type = yield* readQuery( + cwd, + "query($id: ID!) { viewer { login } node(id: $id) { ... on IssueComment { bodyText url author { login } } ... on DiscussionComment { bodyText url author { login } } ... on PullRequestReview { bodyText url author { login } } ... on PullRequestReviewComment { bodyText url author { login } } } }", + { id: responseId }, + ); + yield* checkViewer(result.data.viewer.login); + const comment = result.data.node; + if (!comment?.url || comment.bodyText === undefined || !comment.author) + return yield* new GitHubReplyReadError({ reason: "unavailable" }); + reply = { + url: comment.url, + text: comment.bodyText.slice(0, 6000), + author: comment.author.login, + }; + } + if (!viewer) return yield* new GitHubReplyReadError({ reason: "unavailable" }); + return { + viewer, + ...(discussionCommentId === undefined ? {} : { discussionCommentId }), + reply, + baseline, + }; + }); + const read = Effect.fn("GitHubReplyReader.read")(function* ( + input: Parameters[0], + ) { + const credential = yield* api.credential("github.com").pipe(Effect.mapError(mapApiError)); + return yield* readConversation(input, credential.fingerprint).pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, { + host: "github.com", + token: credential.token, + credentialFingerprint: credential.fingerprint, + }), + Effect.provideService(SourceControlRateLimit.CredentialScope, credential.fingerprint), + ); + }); + return GitHubReplyReader.of({ read }); +}); + +export const layer = Layer.effect(GitHubReplyReader, make); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 69a204ccd8a35..b8508cc3ec855 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -95,6 +95,7 @@ import { readPastedComposerContext } from "./composerInlineTokenPaste"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; +import { useOpenChangeRequestLink } from "../lib/openPullRequestLink"; import { deriveProviderSubagentStatus, deriveReportedModelSelection, @@ -153,6 +154,7 @@ import { lazy, memo, type SetStateAction, + type MouseEvent, Suspense, useCallback, useEffect, @@ -1591,6 +1593,7 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + const openChangeRequestLink = useOpenChangeRequestLink(routeThreadRef); const currentRouteThreadKeyRef = useRef(routeThreadKey); useLayoutEffect(() => { currentRouteThreadKeyRef.current = routeThreadKey; @@ -7268,6 +7271,26 @@ export default function ChatView(props: ChatViewProps) { setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, activeThreadShell, unsnoozeThreadMutation, updateThreadMetadata]); + const openGitHubConversation = useCallback( + (event: MouseEvent, url: string) => { + if (event.metaKey || event.ctrlKey) return; + event.preventDefault(); + event.stopPropagation(); + if (openChangeRequestLink(event, url) || !activeThreadRef) return; + void openUrlInPreview({ threadRef: activeThreadRef, url, openPreview }).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open GitHub conversation", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, + [activeThreadRef, openChangeRequestLink, openPreview], + ); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the @@ -7605,7 +7628,8 @@ export default function ChatView(props: ChatViewProps) { ]); // Settled, snoozed, and woke are thread state, not composer actions: each // gets one quiet line after the last message instead of a banner. A woken - // thread announces itself here, not just in the sidebar pill. Dismissing + // timed snooze announces itself here as well as in the sidebar pill; GitHub + // feedback wakes use only the unread state. Dismissing a timed wake // marks the wake as seen (same acknowledgment as the pill); sending a // message clears it as a side effect of the send path. // Memoized: it is the timeline's list footer, and a new element re-renders that footer. @@ -7616,9 +7640,27 @@ export default function ChatView(props: ChatViewProps) { } label={ - activeThreadShell?.snoozedUntil - ? `Snoozed, ${formatRelativeTimeUntilLabel(activeThreadShell.snoozedUntil)}` - : "Snoozed" + activeThreadShell?.githubReplySnooze ? ( + <> + {activeThreadShell.githubReplySnooze.wakeCondition === "changes-requested" + ? "Waiting for changes to be requested" + : "Waiting for a GitHub reply"} + {" · "} + + openGitHubConversation(event, activeThreadShell.githubReplySnooze!.url) + } + > + View + + + ) : activeThreadShell?.snoozedUntil ? ( + `Snoozed, ${formatRelativeTimeUntilLabel(activeThreadShell.snoozedUntil)}` + ) : ( + "Snoozed" + ) } actionLabel={isUnsnoozing ? "Waking..." : "Wake now"} actionDisabled={!canOperateThread || isUnsnoozing} @@ -7636,7 +7678,7 @@ export default function ChatView(props: ChatViewProps) { actionDisabled={!canOperateThread || isUnsettling} onAction={() => void handleUnsettleActiveThread()} /> - ) : activeThreadWokeVisible ? ( + ) : activeThreadWokeVisible && !activeThreadShell?.githubReplyNotice ? ( } label="Woke from snooze" @@ -7647,8 +7689,8 @@ export default function ChatView(props: ChatViewProps) { }, [ acknowledgeActiveThreadWoke, activeThreadSettled, - activeThreadShell?.settledAt, - activeThreadShell?.snoozedUntil, + activeThreadShell, + openGitHubConversation, activeThreadSnoozed, activeThreadWokeVisible, canOperateThread, diff --git a/apps/web/src/components/GitHubReplySnoozeMenu.tsx b/apps/web/src/components/GitHubReplySnoozeMenu.tsx new file mode 100644 index 0000000000000..f353ea41e81f2 --- /dev/null +++ b/apps/web/src/components/GitHubReplySnoozeMenu.tsx @@ -0,0 +1,71 @@ +import { + githubReplyConversationLabel, + githubReplyConversationsForCondition, + type GitHubReplySnoozeChoice, + type GitHubReplyConversation, +} from "@t3tools/client-runtime/github-reply-conversations"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MenuItem, MenuSub, MenuSubPopup, MenuSubTrigger } from "./ui/menu"; + +/** Several saved conversations extend the current menu from this row. */ +export function GitHubReplySnoozeMenuItem({ + conversations, + onSnooze, + wakeCondition, +}: { + conversations: ReadonlyArray; + onSnooze: (choice: GitHubReplySnoozeChoice) => void; + wakeCondition?: GitHubReplySnoozeChoice["wakeCondition"]; +}) { + conversations = githubReplyConversationsForCondition(conversations, wakeCondition); + const label = + wakeCondition === "changes-requested" ? "Until changes are requested" : "Until a GitHub reply"; + const only = conversations.length === 1 ? conversations[0] : undefined; + if (conversations.length < 2) { + return ( + { + event.stopPropagation(); + if (only) onSnooze({ url: only.url, wakeCondition }); + }} + > + + {label} + {!only ? ( + + {wakeCondition === "changes-requested" + ? "Link an open GitHub PR first" + : "Link a GitHub PR or discussion first"} + + ) : null} + + + ); + } + return ( + + event.stopPropagation()}> + {label} + + + {conversations.map((conversation) => ( + { + event.stopPropagation(); + onSnooze({ url: conversation.url, wakeCondition }); + }} + > + + }> + {githubReplyConversationLabel(conversation)} + + {githubReplyConversationLabel(conversation)} + + + ))} + + + ); +} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 3e4d53d55b63f..d14386ddb08e8 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -69,6 +69,14 @@ export const LinuxIcon: Icon = ({ className, ...props }) => ( ); +// GitHub Octicons comment-discussion-16. Copyright (c) 2026 GitHub Inc., MIT. +// https://github.com/primer/octicons/blob/main/LICENSE +export const GitHubDiscussionIcon: Icon = (props) => ( + +); + export const GitHubIcon: Icon = (props) => ( ) => { - const url = prStatus?.url ?? currentLinkedPr?.url; + (event: React.MouseEvent, targetUrl?: string) => { + const url = targetUrl ?? prStatus?.url ?? currentLinkedPr?.url; if (!url) return; - const openedInRightPanel = openPrLink( - event, - url, - openPullRequestsInRightPanel ? threadRef : undefined, - ); - if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { + const showInRightPanel = targetUrl !== undefined || openPullRequestsInRightPanel; + const openedInRightPanel = openPrLink(event, url, showInRightPanel ? threadRef : undefined); + if (openedInRightPanel && showInRightPanel && !isActive) { navigateToThread(threadRef); } }, @@ -799,6 +799,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null} + {!pr && !currentLinkedPr && discussion ? ( + + ) : null} {threadStatus && } {canOperateThread && renamingThreadKey === threadKey ? ( : null} {draftIndicator} - {title} - {pinIndicator} - {terminalStatusIcon} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {/* The PR badge stays outside the hover-fading slot: it must - remain visible AND clickable while the row is hovered. Only - the time/jump label yields to the settle affordance. */} - {prBadge} + + {title} + {pinIndicator} + {terminalStatusIcon} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {/* the title yields first; oversized link numbers stay within the space + before the wake control and keep their full value in the tooltip. */} + {linkedItemBadge ? ( + + {linkedItemBadge} + + ) : null} + {sortable?.isDragging ? ( dragDestination ) : ( - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + {hasSnoozeWakeLabel ? ( // Snoozed rows show when they come BACK, not when they were // last touched — the return ticket is the row's whole story. @@ -1901,6 +2013,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onPointerDown={handleActionPointerDown} className={cn( "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + hasSnoozeWakeLabel && + "relative col-start-1 row-start-1 h-full justify-self-end duration-150 group-focus-visible/sidebar-row:pointer-events-auto group-has-[:focus-visible]/sidebar-row:pointer-events-auto group-focus-visible/sidebar-row:opacity-100 group-has-[:focus-visible]/sidebar-row:opacity-100 motion-reduce:transition-none", isWoke && "group-any-hover/sidebar-row:static", )} > @@ -2114,6 +2228,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} {showSnoozeButton ? ( )} {terminalStatusIcon} - {prBadge} + {linkedItemBadge} {diff ? ( +{diff.insertions}{" "} @@ -3669,14 +3790,19 @@ export default function Sidebar() { if ( canonicalSection === optimisticDrop.section && thread.pinnedAt == null && - (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null) + (!optimisticDrop.clearsSnooze || + (thread.snoozedUntil == null && thread.githubReplySnooze == null)) ) { setOptimisticDrop(null); } return; } if (canonicalSection !== optimisticDrop.section) return; - if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return; + if ( + optimisticDrop.clearsSnooze && + (thread.snoozedUntil != null || thread.githubReplySnooze != null) + ) + return; const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; const canonicalDestination = destinationKeys.flatMap((key) => { const canonical = canonicalByKey.get(key); @@ -4175,7 +4301,7 @@ export default function Sidebar() { const performSnooze = useCallback( async ( threadRef: ScopedThreadRef, - preset: Pick, + preset: SnoozeChoice, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { const threadKey = scopedThreadKey(threadRef); @@ -4187,9 +4313,22 @@ export default function Sidebar() { // Snoozing the open thread moves you forward, same as settle — // both park the thread you're done with for now. const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); + const request = snoozeThread(threadRef, "url" in preset ? preset : preset.snoozedUntil); + if ( + "url" in preset && + shouldNavigateAfterThreadPark({ + threadKey, + currentThreadKey: routeThreadKeyRef.current, + action: "snooze", + now: new Date().toISOString(), + thread: readThreadShell(threadRef), + }) + ) { + navigateAfterSnooze?.(); + } + const result = await request; if (result._tag === "Failure") { - // Never navigate away from a thread that did not snooze. + // Rollback restores the row without forcing navigation back to it. return isAtomCommandInterrupted(result) ? ({ status: "interrupted" } as const) : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); @@ -4197,6 +4336,7 @@ export default function Sidebar() { // Only move forward if the user is still on the snoozed thread — // a navigation made during the await wins over ours. if ( + !("url" in preset) && shouldNavigateAfterThreadPark({ threadKey, currentThreadKey: routeThreadKeyRef.current, @@ -4217,7 +4357,7 @@ export default function Sidebar() { const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, - preset: Pick, + preset: SnoozeChoice, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { @@ -4292,6 +4432,9 @@ export default function Sidebar() { pinnedCount: pinnedSelectedThreads.length, }); const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const githubConversations = commonGitHubReplyConversations( + selectedThreads.map(eligibleThreadGitHubConversations), + ); const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -4315,6 +4458,34 @@ export default function Sidebar() { label: `${preset.label} (${preset.whenLabel})`, disabled: !canOperateThreads(selectedThreads), })), + ...(selectedThreads.every( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadGitHubReplySnooze === true, + ) + ? [ + { + ...githubReplySnoozeMenuItem( + githubConversations, + "No shared linked conversation", + ), + separatorBefore: true, + }, + ...(selectedThreads.every( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadGitHubChangesRequestedSnooze === true, + ) + ? [ + githubReplySnoozeMenuItem( + githubConversations, + undefined, + "changes-requested", + ), + ] + : []), + ] + : []), { id: "snooze:custom", label: "Custom…", @@ -4366,7 +4537,9 @@ export default function Sidebar() { const preset = clicked.value === "snooze:custom" ? await requestCustomSnooze() - : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); + : clicked.value.startsWith("snooze:github:") + ? githubReplySnoozeChoice(clicked.value, githubConversations) + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); if (preset) { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. @@ -4602,6 +4775,7 @@ export default function Sidebar() { const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const githubConversations = eligibleThreadGitHubConversations(thread); const threadProjectGroup = projectGroupsRef.current.find((project) => project.memberProjectRefs.some( @@ -4635,10 +4809,17 @@ export default function Sidebar() { settlement: supportsSettlement, autoSettleOptOut: supportsAutoSettleOptOut, snooze: supportsSnooze, + githubReplySnooze: + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadGitHubReplySnooze === true, + githubChangesRequestedSnooze: + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadGitHubChangesRequestedSnooze === true, pinning: supportsPinning, titleRegeneration: supportsTitleRegeneration, }, snoozePresets, + githubConversations, }), position, ), @@ -4649,7 +4830,9 @@ export default function Sidebar() { const preset = clicked.value === "snooze:custom" ? await requestCustomSnooze() - : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); + : clicked.value.startsWith("snooze:github:") + ? githubReplySnoozeChoice(clicked.value, githubConversations) + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); if (preset) attemptSnooze(threadRef, preset); return; } @@ -5286,6 +5469,14 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true } + githubReplySnoozeSupported={ + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadGitHubReplySnooze === true + } + githubChangesRequestedSnoozeSupported={ + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadGitHubChangesRequestedSnooze === true + } pinningSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadPinning === true @@ -5304,11 +5495,15 @@ export default function Sidebar() { actionSweep?.keys.has(threadKey) ? actionSweep.action : null } snoozeWakeLabelText={ - section === "snoozed" && thread.snoozedUntil != null - ? snoozeWakeLabel(thread.snoozedUntil, { - now: new Date().toISOString(), - }) - : null + thread.githubReplySnooze + ? thread.githubReplySnooze.wakeCondition === "changes-requested" + ? "Until change request" + : "Until reply" + : section === "snoozed" && thread.snoozedUntil != null + ? snoozeWakeLabel(thread.snoozedUntil, { + now: new Date().toISOString(), + }) + : null } // All sections: a woken thread can classify straight // into the settled tail (PR merged while snoozed), and diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ad0c9c99a2921..fe7d3f107767a 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -223,6 +223,7 @@ export function ThreadPullRequestBadgeControl({ number, url, status, + tooltipHidden = false, onOpenList, onOpenPullRequest, }: { @@ -232,6 +233,7 @@ export function ThreadPullRequestBadgeControl({ number?: number | undefined; url?: string | undefined; status: PrStatusIndicator | null; + tooltipHidden?: boolean; onOpenList: () => void; onOpenPullRequest: (event: MouseEvent, url?: string) => void; }) { @@ -245,6 +247,7 @@ export function ThreadPullRequestBadgeControl({ url={url} number={number} status={status} + tooltipHidden={tooltipHidden} pullRequests={pullRequests} onOpenList={onOpenList} onOpenPullRequest={onOpenPullRequest} @@ -259,6 +262,7 @@ function PullRequestBadge({ url, number, status, + tooltipHidden, pullRequests, onOpenList, onOpenPullRequest, @@ -269,6 +273,7 @@ function PullRequestBadge({ url: string | undefined; number: number | undefined; status: PrStatusIndicator | null; + tooltipHidden: boolean; pullRequests: ReadonlyArray; onOpenList: () => void; onOpenPullRequest: (event: MouseEvent, url?: string) => void; @@ -305,32 +310,34 @@ function PullRequestBadge({ {/* An element, not bare text: bare text takes its line box from the control, which inherits the row's size, so beside a text-sm title it sat below the other meta. */} - {presentation.text} + {presentation.text} - - {visibleThreadPullRequests(pullRequests).length > 0 ? ( - - ) : number !== undefined && url !== undefined ? ( -
    - + {visibleThreadPullRequests(pullRequests).length > 0 ? ( + -
- ) : null} -
+ ) : number !== undefined && url !== undefined ? ( +
    + +
+ ) : null} + + ) : null} ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b4f879e9a995a..5a4d1e47214e1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -568,7 +568,7 @@ type MessagesTimelineRowContent = summaryKind: ToolGroupSummaryKind; toolSurface?: WorkLogEntry["toolSurface"]; toolIcon?: WorkLogEntry["toolIcon"]; - summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; + summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "discussion"; hasFailure: boolean; } | { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8d2b445a22cea..6a4d4218c550d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,7 +1,7 @@ import { ThreadFindTimelineContext } from "./ThreadFindProvider"; import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext"; -import { ComputerUseAppIcon } from "~/components/Icons"; +import { ComputerUseAppIcon, GitHubDiscussionIcon } from "~/components/Icons"; import { useChatCanvas } from "./ChatCanvasContext"; import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; @@ -4052,6 +4052,8 @@ function toolGroupSummaryIconName( kind: Extract["summaryKind"], ): WorkEntryIconName { switch (kind) { + case "discussion": + return "discussion"; case "pull-request": case "link-pr": case "unlink-pr": @@ -4945,6 +4947,7 @@ type WorkEntryIconName = | "search" | "square-pen" | "terminal" + | "discussion" | "pull-request" | "t3-code" | "wrench" @@ -5126,6 +5129,8 @@ function ToolActivityImageIcon(props: { function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { switch (name) { + case "discussion": + return ; case "pull-request": return ; case "bot": diff --git a/apps/web/src/components/chat/ThreadStatusLine.tsx b/apps/web/src/components/chat/ThreadStatusLine.tsx index aee9ab205e316..fb63a0de31e7a 100644 --- a/apps/web/src/components/chat/ThreadStatusLine.tsx +++ b/apps/web/src/components/chat/ThreadStatusLine.tsx @@ -9,7 +9,7 @@ import { InlineButton } from "../ui/button"; */ export function ThreadStatusLine(props: { readonly icon: ReactNode; - readonly label: string; + readonly label: ReactNode; readonly actionLabel: string; readonly actionDisabled?: boolean; readonly onAction: () => void; diff --git a/apps/web/src/components/githubReplySnoozeMenu.logic.test.ts b/apps/web/src/components/githubReplySnoozeMenu.logic.test.ts new file mode 100644 index 0000000000000..c9f9be9c27f50 --- /dev/null +++ b/apps/web/src/components/githubReplySnoozeMenu.logic.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + githubReplySnoozeChoice, + type GitHubReplyConversation, +} from "@t3tools/client-runtime/github-reply-conversations"; +import { githubReplySnoozeMenuItem } from "./githubReplySnoozeMenu.logic"; + +const pull: GitHubReplyConversation = { + url: "https://github.com/owner/repo/pull/1", + kind: "pull-request", + repository: "owner/repo", + number: 1, + title: "A fix", + isOpenPullRequest: true, +}; +const discussion: GitHubReplyConversation = { + url: "https://github.com/owner/repo/discussions/1", + kind: "discussion", + repository: "owner/repo", + number: 1, + title: "Feedback", +}; + +describe("GitHub reply snooze selection", () => { + it("explains zero links and snoozes a single saved conversation directly", () => { + expect(githubReplySnoozeMenuItem([])).toMatchObject({ + disabled: true, + label: expect.stringContaining("Link a PR or discussion first"), + }); + const item = githubReplySnoozeMenuItem([pull]); + expect(item.children).toBeUndefined(); + expect(githubReplySnoozeChoice(item.id, [pull])).toEqual({ url: pull.url }); + }); + + it("opens a nested choice for several links", () => { + const item = githubReplySnoozeMenuItem([pull, discussion]); + expect(item.children?.map((child) => child.label)).toEqual([ + "A fix", + "Discussion #1 · Feedback", + ]); + expect(githubReplySnoozeChoice(item.id, [pull, discussion])).toBeUndefined(); + expect(githubReplySnoozeChoice(item.children![1]!.id, [pull, discussion])).toEqual({ + url: discussion.url, + }); + expect( + githubReplySnoozeChoice("snooze:github:https://github.com/other/repo/pull/4", [pull]), + ).toBeUndefined(); + }); +}); + +it("selects one open PR directly and several through the requested-changes submenu", () => { + const single = githubReplySnoozeMenuItem([pull, discussion], undefined, "changes-requested"); + expect(single.label).toBe("Until changes are requested"); + expect(single.children).toBeUndefined(); + expect(githubReplySnoozeChoice(single.id, [pull, discussion])).toEqual({ + url: pull.url, + wakeCondition: "changes-requested", + }); + const other = { ...pull, number: 2, url: "https://github.com/owner/repo/pull/2" }; + const several = githubReplySnoozeMenuItem( + [pull, other, discussion], + undefined, + "changes-requested", + ); + expect(several.children).toHaveLength(2); + expect(githubReplySnoozeChoice(several.children![1]!.id, [pull, other, discussion])).toEqual({ + url: other.url, + wakeCondition: "changes-requested", + }); + expect( + githubReplySnoozeMenuItem( + [{ ...pull, isOpenPullRequest: false }, discussion], + undefined, + "changes-requested", + ), + ).toMatchObject({ disabled: true }); + expect( + githubReplySnoozeChoice(single.id, [{ ...pull, isOpenPullRequest: false }]), + ).toBeUndefined(); +}); diff --git a/apps/web/src/components/githubReplySnoozeMenu.logic.ts b/apps/web/src/components/githubReplySnoozeMenu.logic.ts new file mode 100644 index 0000000000000..f8ecaff084248 --- /dev/null +++ b/apps/web/src/components/githubReplySnoozeMenu.logic.ts @@ -0,0 +1,32 @@ +import { + githubReplyConversationLabel, + githubReplyConversationsForCondition, + type GitHubReplyConversation, +} from "@t3tools/client-runtime/github-reply-conversations"; +import type { ContextMenuItem, GitHubReplyWakeCondition } from "@t3tools/contracts"; + +export function githubReplySnoozeMenuItem( + conversations: ReadonlyArray, + emptyLabel = "Link a PR or discussion first", + wakeCondition?: GitHubReplyWakeCondition, +): ContextMenuItem<`snooze:${string}`> { + conversations = githubReplyConversationsForCondition(conversations, wakeCondition); + const changesRequested = wakeCondition === "changes-requested"; + const label = changesRequested ? "Until changes are requested" : "Until a GitHub reply"; + const prefix = changesRequested ? "snooze:github:changes-requested" : "snooze:github"; + if (changesRequested) emptyLabel = "Link an open GitHub PR first"; + const only = conversations.length === 1 ? conversations[0] : undefined; + return { + id: only ? `${prefix}:${only.url}` : prefix, + label: conversations.length === 0 ? `${label} · ${emptyLabel}` : label, + disabled: conversations.length === 0, + ...(conversations.length > 1 + ? { + children: conversations.map((conversation) => ({ + id: `${prefix}:${conversation.url}` as const, + label: githubReplyConversationLabel(conversation), + })), + } + : {}), + }; +} diff --git a/apps/web/src/components/sidebar/SidebarThreadDiscussionBadge.tsx b/apps/web/src/components/sidebar/SidebarThreadDiscussionBadge.tsx new file mode 100644 index 0000000000000..6fe8323cfd357 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadDiscussionBadge.tsx @@ -0,0 +1,46 @@ +import type { ThreadDiscussionLink } from "@t3tools/contracts"; +import type { MouseEvent } from "react"; +import { GitHubDiscussionIcon } from "../Icons"; +import { InlineButton } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function SidebarThreadDiscussionBadge({ + discussion, + iconOnly = false, + tooltipHidden = false, + onOpen, +}: { + discussion: ThreadDiscussionLink; + iconOnly?: boolean; + tooltipHidden?: boolean; + onOpen: (event: MouseEvent, url: string) => void; +}) { + const label = `Discussion #${discussion.number}${discussion.title ? `: ${discussion.title}` : ""}`; + return ( + + } + aria-label={label} + onPointerDown={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + onClick={(event) => onOpen(event, discussion.url)} + /> + } + > + + + {iconOnly ? null : #{discussion.number}} + + + {!tooltipHidden ? ( + +
{label}
+
{discussion.repository}
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 6bae63cffec6c..c8f9d46d3a72a 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -21,9 +21,11 @@ const baseState: ThreadActionMenuState = { settlement: true, autoSettleOptOut: true, snooze: true, + githubReplySnooze: true, pinning: true, titleRegeneration: true, }, + githubConversations: [], snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, ], @@ -99,6 +101,7 @@ describe("buildThreadActionMenuItems", () => { settlement: false, autoSettleOptOut: false, snooze: false, + githubReplySnooze: false, pinning: false, titleRegeneration: false, }, @@ -177,7 +180,22 @@ describe("buildThreadActionMenuItems", () => { (item) => item.id === "snooze", ); expect(snooze?.disabled).toBe(true); - expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour", "snooze:custom"]); + expect(snooze?.children?.map((child) => child.id)).toEqual([ + "snooze:hour", + "snooze:github", + "snooze:custom", + ]); + }); + + it("separates GitHub replies from time presets and hides the option on older servers", () => { + const snooze = buildThreadActionMenuItems(baseState).find((item) => item.id === "snooze"); + expect(snooze?.children?.find((item) => item.id === "snooze:github")?.separatorBefore).toBe( + true, + ); + expect(snooze?.children?.at(-1)).toMatchObject({ id: "snooze:custom", separatorBefore: true }); + expect( + allIds({ ...baseState, supports: { ...baseState.supports, githubReplySnooze: false } }), + ).not.toContain("snooze:github"); }); it("disables title regeneration while one is in flight", () => { @@ -209,6 +227,7 @@ describe("buildThreadActionMenuItems", () => { settlement: false, autoSettleOptOut: false, snooze: false, + githubReplySnooze: false, pinning: false, titleRegeneration: false, }, @@ -244,3 +263,25 @@ describe("buildDraftActionMenuItems", () => { expect(items.at(-1)).toMatchObject({ label: "Discard draft", destructive: true }); }); }); + +it("places changes-requested immediately after reply snooze only when the server supports it", () => { + const state = { + ...baseState, + supports: { ...baseState.supports, githubChangesRequestedSnooze: true }, + }; + const children = buildThreadActionMenuItems(state).find((item) => item.id === "snooze")?.children; + expect(children?.map((item) => item.id)).toEqual([ + "snooze:hour", + "snooze:github", + "snooze:github:changes-requested", + "snooze:custom", + ]); + expect(children?.map((item) => item.separatorBefore === true)).toEqual([ + false, + true, + false, + true, + ]); + expect(allIds(baseState)).not.toContain("snooze:github:changes-requested"); + expect(allIds(baseState)).toContain("snooze:github"); +}); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index f51328266cabd..b71ab3bed49a5 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -1,3 +1,5 @@ +import type { GitHubReplyConversation } from "@t3tools/client-runtime/github-reply-conversations"; +import { githubReplySnoozeMenuItem } from "./githubReplySnoozeMenu.logic"; import type { ContextMenuItem } from "@t3tools/contracts"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; @@ -95,10 +97,13 @@ export interface ThreadActionMenuState { /** Server understands thread.auto-settle.set. */ readonly autoSettleOptOut: boolean; readonly snooze: boolean; + readonly githubReplySnooze: boolean; + readonly githubChangesRequestedSnooze?: boolean; readonly pinning: boolean; readonly titleRegeneration: boolean; }; readonly snoozePresets: ReadonlyArray; + readonly githubConversations: ReadonlyArray; } /** Local navigation, read markers, and copying remain available to read-only clients. */ @@ -163,6 +168,23 @@ export function buildThreadActionMenuItems( id: `snooze:${preset.id}` as const, label: `${preset.label} (${preset.whenLabel})`, })), + ...(state.supports.githubReplySnooze + ? [ + { + ...githubReplySnoozeMenuItem(state.githubConversations), + separatorBefore: true, + }, + ...(state.supports.githubChangesRequestedSnooze + ? [ + githubReplySnoozeMenuItem( + state.githubConversations, + undefined, + "changes-requested", + ), + ] + : []), + ] + : []), { id: "snooze:custom" as const, label: "Custom…", separatorBefore: true }, ], }, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 2fda09240b6b2..bec4f76572344 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -1,5 +1,10 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { requestCustomSnooze } from "../components/CustomSnoozeDialog"; +import { + eligibleThreadGitHubConversations, + githubReplySnoozeChoice, +} from "@t3tools/client-runtime/github-reply-conversations"; + import { type AtomCommandResult, isAtomCommandInterrupted, @@ -30,6 +35,8 @@ import { readEnvironmentSupportsPinning, readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, + readEnvironmentSupportsGitHubReplySnooze, + readEnvironmentSupportsGitHubChangesRequestedSnooze, readEnvironmentSupportsTitleRegeneration, readThreadShell, useProjects, @@ -142,11 +149,16 @@ export function useThreadActionMenu(input: { settlement: readEnvironmentSupportsSettlement(threadRef.environmentId), autoSettleOptOut: readEnvironmentSupportsAutoSettleOptOut(threadRef.environmentId), snooze: readEnvironmentSupportsSnooze(threadRef.environmentId), + githubReplySnooze: readEnvironmentSupportsGitHubReplySnooze(threadRef.environmentId), + githubChangesRequestedSnooze: readEnvironmentSupportsGitHubChangesRequestedSnooze( + threadRef.environmentId, + ), pinning: readEnvironmentSupportsPinning(threadRef.environmentId), titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const githubConversations = eligibleThreadGitHubConversations(thread); const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), branch: thread.branch ?? null, @@ -160,6 +172,7 @@ export function useThreadActionMenu(input: { isRunning: !threadRuntimeCanArchive(thread.runtime), supports, snoozePresets, + githubConversations, }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -178,9 +191,14 @@ export function useThreadActionMenu(input: { const preset = action === "snooze:custom" ? await requestCustomSnooze() - : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); + : action.startsWith("snooze:github:") + ? githubReplySnoozeChoice(action, githubConversations) + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); if (!preset) return; - const result = await snoozeThread(threadRef, preset.snoozedUntil); + const result = await snoozeThread( + threadRef, + "url" in preset ? preset : preset.snoozedUntil, + ); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); } diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index b0093f4a0a128..3040bcbff33f5 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -4,7 +4,15 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + githubReplySnoozeFailureMessage, + type GitHubReplySnoozeChoice, +} from "@t3tools/client-runtime/github-reply-conversations"; import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { @@ -393,6 +401,7 @@ export function useThreadActions() { const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; + const githubReplySnooze = thread.githubReplySnooze ?? null; const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef)); const archiveResult = await archiveThreadMutation({ environmentId: threadRef.environmentId, @@ -412,7 +421,18 @@ export function useThreadActions() { action: "Archived", claim: action, // Undo also brings the reader back when archiving moved them to a draft. - undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }), + undo: async () => { + const unarchived = await unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }); + if (unarchived._tag !== "Success" || githubReplySnooze === null) return unarchived; + return snoozeThreadMutation({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + url: githubReplySnooze.url, + resumeFrom: githubReplySnooze, + }, + }); + }, failureTitle: "Failed to undo archive", }); @@ -434,6 +454,7 @@ export function useThreadActions() { markThreadVisited, resolveThreadTarget, unarchiveThread, + snoozeThreadMutation, ], ); @@ -790,6 +811,7 @@ export function useThreadActions() { const pinOrderKey = resolved?.thread.pinnedAt != null ? resolved.thread.pinOrderKey : null; const wasPinned = resolved?.thread.pinnedAt != null; const snoozedUntil = resolved?.thread.snoozedUntil ?? null; + const githubReplySnooze = resolved?.thread.githubReplySnooze ?? null; // An older unpin/snooze Undo would re-pin or re-snooze, and the server // treats either as a promotion that un-settles; settling supersedes them. ThreadUndo.invalidate("pin", scopedThreadKey(target)); @@ -819,10 +841,23 @@ export function useThreadActions() { ); if (pinned._tag !== "Success") return pinned; } + if (githubReplySnooze !== null) { + return snoozeThreadMutation({ + environmentId: target.environmentId, + input: { + threadId: target.threadId, + url: githubReplySnooze.url, + resumeFrom: githubReplySnooze, + }, + }); + } if (snoozedUntil !== null) { return snoozeThreadMutation({ environmentId: target.environmentId, - input: { threadId: target.threadId, snoozedUntil }, + input: { + threadId: target.threadId, + snoozedUntil, + }, }); } return unsettled; @@ -929,9 +964,18 @@ export function useThreadActions() { ); const snoozeThread = useCallback( - async (target: ScopedThreadRef, snoozedUntil: string) => { + async (target: ScopedThreadRef, snoozedUntil: string | GitHubReplySnoozeChoice) => { // Version skew: never send the command to a server that predates it. - if (!readEnvironmentSupportsSnooze(target.environmentId)) { + if ( + !readEnvironmentSupportsSnooze(target.environmentId) || + (typeof snoozedUntil !== "string" && + appAtomRegistry.get(environmentServerConfigsAtom).get(target.environmentId)?.environment + .capabilities.threadGitHubReplySnooze !== true) || + (typeof snoozedUntil !== "string" && + snoozedUntil.wakeCondition === "changes-requested" && + appAtomRegistry.get(environmentServerConfigsAtom).get(target.environmentId)?.environment + .capabilities.threadGitHubChangesRequestedSnooze !== true) + ) { return AsyncResult.failure( Cause.fail( new ThreadSnoozeUnsupportedError({ @@ -958,10 +1002,16 @@ export function useThreadActions() { const action = ThreadUndo.begin("snooze", scopedThreadKey(target)); const result = await snoozeThreadMutation({ environmentId: target.environmentId, - input: { threadId: target.threadId, snoozedUntil }, + input: { + threadId: target.threadId, + ...(typeof snoozedUntil === "string" ? { snoozedUntil } : snoozedUntil), + }, }); if (result._tag !== "Success") { action.finish(); + if (typeof snoozedUntil !== "string" && !isAtomCommandInterrupted(result)) { + return AsyncResult.failure(Cause.fail(new Error(githubReplySnoozeFailureMessage))); + } return result; } // Snooze hides the row, so keep its confirmation in the sidebar. diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index da65bf2ef931f..acc02cce68990 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { CommandId, EnvironmentId, ThreadId, type GitHubReplySnooze } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { useThreadActions } from "./useThreadActions"; @@ -43,6 +43,7 @@ const threadShell = vi.hoisted(() => ({ pinOrderKey: "a0", pinnedAt: null as string | null, snoozedUntil: null as string | null, + githubReplySnooze: null as GitHubReplySnooze | null, projectId: "project", environmentId: "undo-env", session: null, @@ -99,6 +100,7 @@ beforeEach(() => { router.state.matches[0]!.params = {}; threadShell.pinnedAt = null; threadShell.snoozedUntil = null; + threadShell.githubReplySnooze = null; }); afterEach(() => { vi.runAllTimers(); @@ -152,6 +154,28 @@ describe("archive Undo", () => { ); }); + it("restores the original GitHub reply baseline after archive Undo", async () => { + const saved: GitHubReplySnooze = { + requestId: CommandId.make("archive-watch"), + url: "https://github.com/team/repo/discussions/1", + startedAt: "2026-01-01T00:00:00.100Z", + nextCheckAt: "2026-01-01T00:02:00.000Z", + status: "watching", + failures: 0, + viewer: "me", + baseline: { latestAt: "2026-01-01T00:00:00Z", ids: ["existing"] }, + }; + threadShell.githubReplySnooze = saved; + await useThreadActions().archiveThread(target); + threadShell.githubReplySnooze = null; + await currentUndo()(); + expect(commands.unarchive).toHaveBeenCalledOnce(); + expect(commands.snooze).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, url: saved.url, resumeFrom: saved }, + }); + }); + it("stays put when the archived thread was not open", async () => { const actions = useThreadActions(); await actions.archiveThread(target); @@ -199,6 +223,31 @@ describe("settle and snooze Undo", () => { }); }); + it.each([undefined, "changes-requested"] as const)( + "restores the GitHub reply baseline and %s condition when undoing settle", + async (wakeCondition) => { + const saved: GitHubReplySnooze = { + wakeCondition, + requestId: CommandId.make("saved-watch"), + url: "https://github.com/team/repo/pull/1", + startedAt: "2026-01-01T00:00:00.000Z", + nextCheckAt: "2026-01-01T00:02:00.000Z", + status: "watching", + failures: 0, + viewer: "me", + }; + threadShell.githubReplySnooze = saved; + const actions = useThreadActions(); + await actions.settleThread(target); + threadShell.githubReplySnooze = null; + await currentUndo()(); + expect(commands.snooze).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, url: saved.url, resumeFrom: saved }, + }); + }, + ); + it("expires an older unpin Undo when the thread is settled", async () => { const actions = useThreadActions(); await actions.unpinThread(target); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index aa9b650fb5b50..049fe51f14149 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -278,6 +278,22 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo ); } +export function readEnvironmentSupportsGitHubChangesRequestedSnooze( + environmentId: EnvironmentId, +): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadGitHubChangesRequestedSnooze === true + ); +} + +export function readEnvironmentSupportsGitHubReplySnooze(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadGitHubReplySnooze === true + ); +} + /** Whether the environment's server understands thread.visit/mark-unread and projects lastVisitedAt on thread shells. Same version-skew contract as settlement: against older servers, clients keep the browser-local visited diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2e6785db07b98..b18fe27e54689 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -221,6 +221,11 @@ returns to your inbox. Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked out from the matching organization and repository. +## GitHub discussions + +Ask your agent to link a GitHub discussion to its thread. Linked discussions let you +snooze until someone replies. Open the link to read or respond on GitHub. + ## GitHub stacks The Pull Requests page shows each PR's position in its GitHub stack. Open the stack badge in a diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 80af3a815b676..a7b48ab61db46 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -231,3 +231,33 @@ Choose **Snooze → Custom…** from a thread's menu to pick a date and time in local time zone, or a duration in minutes, hours, or days. Durations start when you confirm; one day means 24 hours. On web and desktop, you can also snooze several selected threads together. Choose **Wake thread** to bring a thread back early. + +To wait for feedback on GitHub, choose **Snooze → Until a GitHub reply**. T3 uses the +thread's linked github.com PRs and discussions. With one linked conversation, +snooze starts immediately; with several, choose one from the menu. Ask your agent +to link a discussion or PR first if none is linked. Open, merged and closed PRs +all work. You can also ask your agent to snooze the thread for a conversation URL. +T3 reads existing replies before hiding the thread. Replies observed during setup +count as existing. If the check fails, T3 does not apply the snooze. + +T3 checks from the thread's environment using its configured GitHub credentials, +usually every two minutes. New comments, submitted reviews and inline review +replies from other accounts bring the thread back. +Your own comments, existing comments, edits, reactions, CI changes and merge state +do not wake it. PR links watch the whole PR conversation. A discussion link watches +its comments and replies; a discussion comment link limits the watch to that +comment's reply thread. No agent starts or replies when feedback arrives. A running +agent can finish its current turn while the thread waits. + +Choose **Snooze → Until changes are requested** to wait for a formal GitHub +"Request changes" review on a linked open PR. Comments, inline replies, approvals +and your own reviews do not wake it. A newly submitted review counts even when +its text is empty or its draft predates the snooze. There is no time limit. + +To wait again, choose the same GitHub snooze option from the thread's menu. + +The watch survives a server restart. **Wake thread**, a different snooze, settle, +archive or delete cancels it. Sending a message also wakes the thread. If access fails +repeatedly, T3 returns the thread to the inbox. Rate limits pause +checks until GitHub permits them again. Other GitHub hosts and non-GitHub services +are not supported. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index aea6ead6b9433..52d00f356e103 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./github-reply-conversations": { + "types": "./src/githubReplyConversations.ts", + "default": "./src/githubReplyConversations.ts" + }, "./composerThreadItems": { "types": "./src/composerThreadItems.ts", "default": "./src/composerThreadItems.ts" diff --git a/packages/client-runtime/src/githubReplyConversations.test.ts b/packages/client-runtime/src/githubReplyConversations.test.ts new file mode 100644 index 0000000000000..79ee88ef07e46 --- /dev/null +++ b/packages/client-runtime/src/githubReplyConversations.test.ts @@ -0,0 +1,161 @@ +import type { ThreadDiscussionLink, ThreadPullRequestLink } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + commonGitHubReplyConversations, + eligibleThreadGitHubConversations, + githubReplyConversationsForCondition, + githubReplySnoozeChoice, +} from "./githubReplyConversations.ts"; + +const linkedAt = "2026-10-07T12:00:00.000Z"; +const pull: ThreadPullRequestLink = { + host: "github.com", + repository: "owner/repo", + number: 12, + url: "https://github.com/owner/repo/pull/12", + source: "manual", + linkedAt, + snapshot: null, + stack: null, +}; +const discussion: ThreadDiscussionLink = { + host: "github.com", + repository: "owner/repo", + number: 4, + url: "https://github.com/owner/repo/discussions/4", + title: "Feedback", + source: "agent", + linkedAt, +}; + +describe("eligibleThreadGitHubConversations", () => { + it("offers linked GitHub conversations, including closed PRs, but omits other hosts and dismissed stack links", () => { + const conversations = eligibleThreadGitHubConversations({ + pullRequests: [ + { + ...pull, + snapshot: { + state: "closed", + title: "Closed work", + headBranch: "work", + baseBranch: "main", + isDraft: false, + updatedAt: linkedAt, + syncedAt: linkedAt, + }, + }, + { + ...pull, + number: 13, + url: "https://github.com/owner/repo/pull/13", + source: "stack-dismissed", + }, + { ...pull, host: "gitlab.com", url: "https://gitlab.com/owner/repo/-/merge_requests/12" }, + ], + discussions: [discussion], + }); + expect(conversations).toEqual([ + { + url: pull.url, + kind: "pull-request", + repository: "owner/repo", + number: 12, + title: "Closed work", + isOpenPullRequest: false, + }, + { + url: discussion.url, + kind: "discussion", + repository: "owner/repo", + number: 4, + title: "Feedback", + isOpenPullRequest: false, + }, + ]); + }); + + it("deduplicates PR anchors and case variations without combining PRs with discussions", () => { + expect( + eligibleThreadGitHubConversations({ + pullRequests: [ + pull, + { ...pull, url: "https://github.com/Owner/Repo/pull/12#issuecomment-9" }, + ], + discussions: [ + { ...discussion, number: 12, url: "https://github.com/owner/repo/discussions/12" }, + ], + }), + ).toHaveLength(2); + }); + + it("limits bulk selection to a conversation saved on every selected thread", () => { + const both = eligibleThreadGitHubConversations({ + pullRequests: [pull], + discussions: [discussion], + }); + const one = eligibleThreadGitHubConversations({ discussions: [discussion] }); + expect(commonGitHubReplyConversations([both, one])).toEqual(one); + expect(commonGitHubReplyConversations([both, []])).toEqual([]); + expect(commonGitHubReplyConversations([])).toEqual([]); + }); +}); + +it("limits requested-changes targets to linked open GitHub PRs on every selected thread", () => { + const snapshot = { + state: "open" as const, + title: "Open", + headBranch: "work", + baseBranch: "main", + isDraft: false, + updatedAt: linkedAt, + syncedAt: linkedAt, + }; + const conversations = eligibleThreadGitHubConversations({ + pullRequests: [ + { ...pull, snapshot }, + { + ...pull, + number: 2, + url: "https://github.com/owner/repo/pull/2", + snapshot: { ...snapshot, state: "closed" }, + }, + { + ...pull, + number: 3, + url: "https://github.com/owner/repo/pull/3", + snapshot: { ...snapshot, state: "merged" }, + }, + { ...pull, number: 4, url: "https://github.com/owner/repo/pull/4", snapshot: null }, + { + ...pull, + number: 5, + url: "https://github.com/owner/repo/pull/5", + snapshot, + source: "stack-dismissed", + }, + { + ...pull, + number: 6, + url: "https://ghe.example/owner/repo/pull/6", + host: "ghe.example", + snapshot, + }, + ], + discussions: [discussion], + }); + expect( + githubReplyConversationsForCondition(conversations, "changes-requested").map( + (item) => item.url, + ), + ).toEqual([pull.url]); + expect(githubReplyConversationsForCondition(conversations)).toHaveLength(5); + const closedElsewhere = eligibleThreadGitHubConversations({ + pullRequests: [{ ...pull, snapshot: { ...snapshot, state: "closed" } }], + }); + const shared = commonGitHubReplyConversations([conversations, closedElsewhere]); + expect(shared).toHaveLength(1); + expect(githubReplyConversationsForCondition(shared, "changes-requested")).toEqual([]); + expect( + githubReplySnoozeChoice(`snooze:github:changes-requested:${discussion.url}`, conversations), + ).toBeUndefined(); +}); diff --git a/packages/client-runtime/src/githubReplyConversations.ts b/packages/client-runtime/src/githubReplyConversations.ts new file mode 100644 index 0000000000000..983c7bc26398e --- /dev/null +++ b/packages/client-runtime/src/githubReplyConversations.ts @@ -0,0 +1,119 @@ +import { + parseGitHubConversationUrl, + type GitHubReplyWakeCondition, + type GitHubReplySnooze, + type ThreadDiscussionLink, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; + +export const githubReplySnoozeFailureMessage = + "GitHub reply snooze could not be set up. Try again."; + +export interface GitHubReplyConversation { + readonly url: string; + readonly kind: "pull-request" | "discussion"; + readonly repository: string; + readonly number: number; + readonly title: string | null; + readonly isOpenPullRequest?: boolean; +} + +/** Only saved GitHub conversations can be selected, including merged and closed PRs. */ +export function eligibleThreadGitHubConversations(thread: { + readonly pullRequests?: ReadonlyArray; + readonly discussions?: ReadonlyArray; +}): ReadonlyArray { + const candidates = [ + ...visibleThreadPullRequests(thread.pullRequests ?? []).map((link) => ({ + url: link.url, + title: link.snapshot?.title ?? null, + isOpenPullRequest: + link.host.toLowerCase() === "github.com" && link.snapshot?.state === "open", + })), + ...(thread.discussions ?? []).map((link) => ({ + url: link.url, + title: link.title, + isOpenPullRequest: false, + })), + ]; + const seen = new Set(); + return candidates.flatMap((candidate) => { + const conversation = parseGitHubConversationUrl(candidate.url); + if (!conversation) return []; + const url = conversation.kind === "pull" ? conversation.url.split("#")[0]! : conversation.url; + const key = url.toLowerCase(); + if (seen.has(key)) return []; + seen.add(key); + return [ + { + url, + kind: conversation.kind === "pull" ? ("pull-request" as const) : ("discussion" as const), + repository: `${conversation.owner}/${conversation.repository}`, + number: conversation.number, + title: candidate.title, + isOpenPullRequest: candidate.isOpenPullRequest, + }, + ]; + }); +} + +export function githubReplyConversationLabel(conversation: GitHubReplyConversation): string { + if (conversation.kind === "pull-request" && conversation.title) return conversation.title; + const kind = conversation.kind === "pull-request" ? "PR" : "Discussion"; + return `${kind} #${conversation.number}${conversation.title ? ` · ${conversation.title}` : ` · ${conversation.repository}`}`; +} + +/** A bulk action must watch a conversation linked to every selected thread. */ +export function commonGitHubReplyConversations( + groups: ReadonlyArray>, +): ReadonlyArray { + return ( + groups[0]?.flatMap((conversation) => { + const matches = groups.map((group) => + group.find((candidate) => candidate.url.toLowerCase() === conversation.url.toLowerCase()), + ); + if (matches.some((match) => match === undefined)) return []; + return [ + { + ...conversation, + ...(conversation.kind === "pull-request" + ? { + isOpenPullRequest: matches.every((match) => match?.isOpenPullRequest === true), + } + : {}), + }, + ]; + }) ?? [] + ); +} + +export type GitHubReplySnoozeChoice = Pick; + +export function githubReplyConversationsForCondition( + conversations: ReadonlyArray, + wakeCondition?: GitHubReplyWakeCondition, +): ReadonlyArray { + return wakeCondition === "changes-requested" + ? conversations.filter( + (conversation) => + conversation.kind === "pull-request" && conversation.isOpenPullRequest === true, + ) + : conversations; +} + +/** resolve menu actions against saved links, including the condition's eligibility. */ +export function githubReplySnoozeChoice( + action: string, + conversations: ReadonlyArray, +): GitHubReplySnoozeChoice | undefined { + const changesRequested = action.startsWith("snooze:github:changes-requested:"); + const wakeCondition = changesRequested ? "changes-requested" : undefined; + const prefix = changesRequested ? "snooze:github:changes-requested:" : "snooze:github:"; + const conversation = githubReplyConversationsForCondition(conversations, wakeCondition).find( + (candidate) => `${prefix}${candidate.url}` === action, + ); + return conversation + ? { url: conversation.url, ...(wakeCondition ? { wakeCondition } : {}) } + : undefined; +} diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 84f4a2ad3cff8..4c7c52b61552f 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -1,6 +1,7 @@ import { remapComposerContextAttachments } from "@t3tools/shared/composerContextReferences"; import { type ThreadLinkedPullRequest, + type GitHubReplySnooze, CommandId, CheckpointId, CheckpointScopeId, @@ -106,9 +107,15 @@ export interface ReorderActiveThreadInput extends ThreadCommandInput { readonly orderKey: string; } -export interface SnoozeThreadInput extends ThreadCommandInput { - readonly snoozedUntil: string; -} +export type SnoozeThreadInput = ThreadCommandInput & + ( + | { readonly snoozedUntil: string } + | { + readonly url: string; + readonly wakeCondition?: GitHubReplySnooze["wakeCondition"]; + readonly resumeFrom?: GitHubReplySnooze; + } + ); export interface UnsnoozeThreadInput extends ThreadCommandInput { readonly reason: "user"; @@ -518,10 +525,16 @@ export const snoozeThread = Effect.fn("EnvironmentCommands.snoozeThread")(functi input: SnoozeThreadInput, ) { return yield* dispatch({ - type: "thread.snooze", commandId: yield* allocateCommandId(input), threadId: input.threadId, - snoozedUntil: input.snoozedUntil, + ...("url" in input + ? { + type: "thread.github-reply.snooze" as const, + url: input.url, + ...(input.wakeCondition === undefined ? {} : { wakeCondition: input.wakeCondition }), + ...(input.resumeFrom === undefined ? {} : { resumeFrom: input.resumeFrom }), + } + : { type: "thread.snooze" as const, snoozedUntil: input.snoozedUntil }), }); }); diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index e9049239e7e7e..6a04c5a0bcf67 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -1,3 +1,4 @@ +import type { GitHubReplySnooze, GitHubReplyNotice } from "@t3tools/contracts"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; import type { @@ -124,6 +125,8 @@ export interface EnvironmentThreadShell { readonly settledAt: string | null; readonly unsettledAt: string | null; readonly snoozedUntil: string | null; + readonly githubReplySnooze?: GitHubReplySnooze | null; + readonly githubReplyNotice?: GitHubReplyNotice | null; readonly snoozedAt: string | null; readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null; readonly pinnedAt: string | null; @@ -133,6 +136,7 @@ export interface EnvironmentThreadShell { /** Slot in the user-arranged active order; null for keyless active threads. */ readonly activeOrderKey: string | null; readonly pullRequests: ReadonlyArray; + readonly discussions?: ReadonlyArray; readonly linkedPullRequest?: ThreadLinkedPullRequest | null; readonly branchPullRequest?: ThreadLinkedPullRequest | null; /** @@ -241,6 +245,7 @@ export function presentThreadShell( branch: thread.branch, worktreePath: thread.worktreePath, pullRequests: threadPullRequestsOf(thread), + discussions: thread.discussions ?? [], linkedPullRequest: thread.linkedPullRequest ?? null, branchPullRequest: thread.branchPullRequest ?? null, lineage: thread.lineage, @@ -270,6 +275,8 @@ export function presentThreadShell( settledAt: nullableIso(thread.settledAt), unsettledAt: nullableIso(thread.unsettledAt ?? null), snoozedUntil: nullableIso(thread.snoozedUntil ?? null), + githubReplySnooze: thread.githubReplySnooze ?? null, + githubReplyNotice: thread.githubReplyNotice ?? null, snoozedAt: nullableIso(thread.snoozedAt ?? null), limitRecovery: thread.limitRecovery ?? null, pinnedAt: nullableIso(thread.pinnedAt ?? null), diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index cf36760baecb7..c22d83364a155 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { CommandId, + MessageId, RuntimeRequestId, EnvironmentId, ORCHESTRATION_V2_WS_METHODS, @@ -24,6 +25,7 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { RpcSession } from "../rpc/session.ts"; import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; +import { isAtomCommandInterrupted } from "./runtime.ts"; const ENVIRONMENT_ID = EnvironmentId.make("remote"); const THREAD_ID = ThreadId.make("thread"); @@ -125,6 +127,175 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* () { }); describe("remote thread lifecycle commands", () => { + const githubUrl = "https://github.com/t3tools/t3code/pull/1"; + + it.effect( + "snoozes immediately while GitHub setup runs and restores the previous state on failure", + () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const result = h.commands.snooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, url: githubUrl }, + }); + expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject({ + githubReplySnooze: { url: githubUrl, status: "pending" }, + snoozedAt: expect.any(Object), + snoozedUntil: null, + }); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe("thread.github-reply.snooze"); + expect(h.registry.get(h.snapshotAtom(ENVIRONMENT_ID))).toBe(SNAPSHOT); + yield* Deferred.fail(request.reply, new Error("GitHub unavailable")); + const failure = yield* Effect.promise(() => result); + expect(failure._tag).toBe("Failure"); + expect(isAtomCommandInterrupted(failure)).toBe(false); + expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); + }), + ); + + it.effect.each([undefined, "changes-requested"] as const)( + "keeps GitHub snoozed through acknowledgement and adopts the server watch with %s", + (wakeCondition) => + Effect.gen(function* () { + const h = yield* makeHarness(); + const result = h.commands.snooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, url: githubUrl, wakeCondition }, + }); + const request = yield* Queue.take(h.requests); + expect(request.command).toMatchObject({ + type: "thread.github-reply.snooze", + ...(wakeCondition === undefined ? {} : { wakeCondition }), + }); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.githubReplySnooze?.wakeCondition).toBe( + wakeCondition, + ); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => result))._tag).toBe("Success"); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.githubReplySnooze?.url).toBe(githubUrl); + const confirmed = { + ...SNAPSHOT, + snapshotSequence: 2, + threads: [ + { + ...SNAPSHOT.threads[0]!, + snoozedAt: NOW, + githubReplySnooze: { + requestId: request.command.commandId, + url: githubUrl, + wakeCondition, + startedAt: DateTime.formatIso(NOW), + nextCheckAt: DateTime.formatIso(NOW), + status: "watching" as const, + failures: 0, + viewer: "ash", + baseline: { latestAt: null, ids: [] }, + }, + }, + ], + }; + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); + expect(h.registry.get(h.visibleAtom)).toBe(confirmed); + }), + ); + + it.effect.each(["success", "failure"] as const)( + "keeps a newer Wake visible and suppresses stale GitHub setup %s", + (outcome) => + Effect.gen(function* () { + const h = yield* makeHarness(); + const result = h.commands.snooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, url: githubUrl }, + }); + const first = yield* Queue.take(h.requests); + const wake = h.commands.unsnooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.githubReplySnooze).toBeNull(); + if (outcome === "success") yield* Deferred.succeed(first.reply, { sequence: 2 }); + else yield* Deferred.fail(first.reply, new Error("GitHub unavailable")); + expect(isAtomCommandInterrupted(yield* Effect.promise(() => result))).toBe(true); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.githubReplySnooze).toBeNull(); + const second = yield* Queue.take(h.requests); + expect(second.command.type).toBe("thread.unsnooze"); + yield* Deferred.succeed(second.reply, { sequence: 3 }); + expect((yield* Effect.promise(() => wake))._tag).toBe("Success"); + const confirmed = { ...SNAPSHOT, snapshotSequence: 3 }; + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); + expect(h.registry.get(h.visibleAtom)).toBe(confirmed); + }), + ); + + it.effect("preserves a newer timed snooze when GitHub setup fails", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const result = h.commands.snooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, url: githubUrl }, + }); + const first = yield* Queue.take(h.requests); + const changed = h.commands.snooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, snoozedUntil: DateTime.formatIso(FUTURE) }, + }); + yield* Deferred.fail(first.reply, new Error("GitHub unavailable")); + expect(isAtomCommandInterrupted(yield* Effect.promise(() => result))).toBe(true); + expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject({ + githubReplySnooze: null, + snoozedUntil: FUTURE, + }); + const second = yield* Queue.take(h.requests); + yield* Deferred.succeed(second.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => changed))._tag).toBe("Success"); + }), + ); + + it.effect.each(["archive", "send"] as const)( + "silences a superseded GitHub failure after a newer %s without dropping that command", + (action) => + Effect.gen(function* () { + const h = yield* makeHarness(); + const result = h.commands.snooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, url: githubUrl }, + }); + const first = yield* Queue.take(h.requests); + const next = + action === "archive" + ? h.commands.archive.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID }, + }) + : h.commands.startTurn.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + message: { + messageId: MessageId.make("new-message"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + dispatchMode: "start", + }, + }); + yield* Deferred.fail(first.reply, new Error("GitHub unavailable")); + expect(isAtomCommandInterrupted(yield* Effect.promise(() => result))).toBe(true); + expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); + const second = yield* Queue.take(h.requests); + expect(second.command.type).toBe( + action === "archive" ? "thread.archive" : "message.dispatch", + ); + yield* Deferred.succeed(second.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => next))._tag).toBe("Success"); + }), + ); + const actions = [ ["settle", {}, { settledOverride: "settled", pinnedAt: null, snoozedUntil: null }], ["unsettle", { reason: "user" }, { settledOverride: "active", settledAt: null }], diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 0bb601b643b48..6e2eebe59457a 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -5,6 +5,7 @@ import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; import { WS_METHODS, + CommandId, type EnvironmentId, type OrchestrationV2ShellSnapshot, } from "@t3tools/contracts"; @@ -405,7 +406,7 @@ export function createThreadEnvironmentAtoms( }), }; const optimistic = createOptimisticThreadLifecycle(snapshotAtom); - return { + const lifecycle = { ...commands, snapshotAtom: optimistic.snapshotAtom, settle: optimistic.wrap(commands.settle, (thread, _input, now, accepted) => @@ -424,6 +425,7 @@ export function createThreadEnvironmentAtoms( pinOrderKey: null, snoozedAt: null, snoozedUntil: null, + githubReplySnooze: null, }, ), unsettle: optimistic.wrap(commands.unsettle, (thread, input, now) => ({ @@ -432,26 +434,52 @@ export function createThreadEnvironmentAtoms( settledAt: null, unsettledAt: thread.settledOverride === "active" ? (thread.unsettledAt ?? null) : now, })), - snooze: optimistic.wrap(commands.snooze, (thread, input, now, accepted) => - (!accepted && - (thread.pendingRuntimeRequest !== null || - ["preparing", "queued", "starting"].includes(thread.status))) || - !(Date.parse(input.snoozedUntil) > DateTime.toEpochMillis(now)) - ? thread - : { - ...thread, - pendingRuntimeRequest: null, - snoozedUntil: DateTime.makeUnsafe(input.snoozedUntil), - snoozedAt: - thread.snoozedUntil != null && - DateTime.formatIso(thread.snoozedUntil) === input.snoozedUntil - ? (thread.snoozedAt ?? now) - : now, - }, + snooze: optimistic.wrap( + commands.snooze, + (thread, input, now, accepted) => + (!accepted && + (thread.pendingRuntimeRequest !== null || + ["preparing", "queued", "starting"].includes(thread.status))) || + (!("url" in input) && !(Date.parse(input.snoozedUntil) > DateTime.toEpochMillis(now))) + ? thread + : "url" in input + ? { + ...thread, + pendingRuntimeRequest: null, + snoozedUntil: null, + snoozedAt: now, + settledOverride: "active", + settledAt: null, + githubReplyNotice: null, + githubReplySnooze: { + requestId: + input.commandId ?? CommandId.make(`optimistic:${DateTime.toEpochMillis(now)}`), + url: input.url, + wakeCondition: input.wakeCondition ?? input.resumeFrom?.wakeCondition, + startedAt: DateTime.formatIso(now), + nextCheckAt: DateTime.formatIso(now), + status: "pending", + failures: 0, + }, + } + : { + ...thread, + pendingRuntimeRequest: null, + snoozedUntil: DateTime.makeUnsafe(input.snoozedUntil), + githubReplySnooze: null, + githubReplyNotice: null, + snoozedAt: + thread.snoozedUntil != null && + DateTime.formatIso(thread.snoozedUntil) === input.snoozedUntil + ? (thread.snoozedAt ?? now) + : now, + }, + (input) => "url" in input, ), unsnooze: optimistic.wrap(commands.unsnooze, (thread) => ({ ...thread, snoozedUntil: null, + githubReplySnooze: null, snoozedAt: null, })), setAutoSettle: optimistic.wrap(commands.setAutoSettle, (thread, input, now) => ({ @@ -470,6 +498,7 @@ export function createThreadEnvironmentAtoms( } : {}), snoozedUntil: null, + githubReplySnooze: null, snoozedAt: null, })), unpin: optimistic.wrap(commands.unpin, (thread) => ({ @@ -486,4 +515,16 @@ export function createThreadEnvironmentAtoms( activeOrderKey: input.orderKey, })), }; + return { + ...lifecycle, + archive: optimistic.supersede(lifecycle.archive), + unarchive: optimistic.supersede(lifecycle.unarchive), + delete: optimistic.supersede(lifecycle.delete), + startTurn: optimistic.supersede(lifecycle.startTurn), + snooze: optimistic.supersede(lifecycle.snooze), + unsnooze: optimistic.supersede(lifecycle.unsnooze), + settle: optimistic.supersede(lifecycle.settle), + unsettle: optimistic.supersede(lifecycle.unsettle), + pin: optimistic.supersede(lifecycle.pin), + }; } diff --git a/packages/client-runtime/src/state/threadLifecycle.ts b/packages/client-runtime/src/state/threadLifecycle.ts index f384ddc3d006c..a2ca92a259a8d 100644 --- a/packages/client-runtime/src/state/threadLifecycle.ts +++ b/packages/client-runtime/src/state/threadLifecycle.ts @@ -5,7 +5,8 @@ import type { ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { Atom } from "effect/reactivity"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/reactivity"; import type { AtomCommand } from "./runtime.ts"; @@ -13,6 +14,7 @@ interface PendingThreadUpdate { readonly threadId: ThreadId; readonly apply: (thread: OrchestrationV2ThreadShell) => OrchestrationV2ThreadShell; sequence?: number; + superseded?: boolean; } export function createOptimisticThreadLifecycle( @@ -60,6 +62,7 @@ export function createOptimisticThreadLifecycle( now: DateTime.Utc, accepted: boolean, ) => OrchestrationV2ThreadShell, + ignoreSupersededResult?: (input: Input) => boolean, ): typeof command { return { label: command.label, @@ -77,6 +80,7 @@ export function createOptimisticThreadLifecycle( let confirmed = false; try { const result = await command.run(registry, target); + const superseded = update.superseded && ignoreSupersededResult?.(target.input); if (result._tag === "Success") { update.sequence = result.value.sequence; registry.update(pending, (current) => [...current]); @@ -90,7 +94,7 @@ export function createOptimisticThreadLifecycle( reconcile(registry.get(source)); confirmed = true; } - return result; + return superseded ? AsyncResult.failure(Cause.interrupt()) : result; } finally { if (!confirmed) remove(); } @@ -98,5 +102,25 @@ export function createOptimisticThreadLifecycle( }; } - return { snapshotAtom, wrap }; + /** New lifecycle choices silence obsolete results without changing request order or rollback. */ + function supersede< + W extends { + readonly environmentId: EnvironmentId; + readonly input: { readonly threadId: ThreadId }; + }, + A, + E, + >(command: AtomCommand): typeof command { + return { + ...command, + run: (registry, target) => { + for (const update of registry.get(pendingAtom(target.environmentId))) { + if (update.threadId === target.input.threadId) update.superseded = true; + } + return command.run(registry, target); + }, + }; + } + + return { snapshotAtom, wrap, supersede }; } diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 104414a9201d7..d310f447c2228 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,3 +1,4 @@ +import type { GitHubReplySnooze, GitHubReplyNotice } from "@t3tools/contracts"; // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. import * as DateTime from "effect/DateTime"; @@ -87,6 +88,8 @@ export function hasQueuedTurnStart( */ export interface ThreadSnoozeShell extends QueuedThreadShell { readonly snoozedUntil?: string | null; + readonly githubReplySnooze?: GitHubReplySnooze | null; + readonly githubReplyNotice?: GitHubReplyNotice | null; readonly snoozedAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; @@ -95,8 +98,8 @@ export interface ThreadSnoozeShell extends QueuedThreadShell { /** * A snoozed thread "raises its hand" when something happens that outranks * the user's snooze: the agent is blocked on them (approval / user input), - * the session failed, or a run completed after the snooze was set — the - * v1 taste of event-based snooze ("something happened" wakes early). + * the session failed, or a run completed after a timed snooze was set. + * A GitHub reply snooze survives normal completion of the registering turn. * Raising a hand never clears the server-side snooze fields; it only stops * the thread from classifying as snoozed. */ @@ -116,6 +119,7 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean return true; } if ( + shell.githubReplySnooze == null && shell.snoozedAt != null && (latestRun?.state === "completed" || latestRun?.status === "completed") && latestRun.completedAt != null && @@ -154,7 +158,8 @@ export function canSnooze( /** * Snoozed resolution: hidden from the inbox while the wake time is in the - * future and the thread has not raised its hand. Timer wakes are derived — + * future, or a GitHub reply watch remains, and the thread has not raised + * its hand. Timer wakes are derived: * no server event fires when snoozedUntil passes; the stale fields simply * stop classifying as snoozed (and feed the woke indicator until the user * visits or re-engages). @@ -163,6 +168,7 @@ export function effectiveSnoozed( shell: ThreadSnoozeShell, options: { readonly now: string }, ): boolean { + if (shell.githubReplySnooze != null) return !threadRaisedHandWhileSnoozed(shell); if (shell.snoozedUntil == null) return false; const wakeAtMs = Date.parse(shell.snoozedUntil); // Malformed data never hides a thread. @@ -186,6 +192,8 @@ export function threadWokeAt( shell: ThreadSnoozeShell, options: { readonly now: string }, ): string | null { + if (shell.githubReplyNotice != null && shell.githubReplySnooze == null) + return shell.githubReplyNotice.receivedAt; if (shell.snoozedUntil == null) return null; const wakeAtMs = Date.parse(shell.snoozedUntil); if (Number.isNaN(wakeAtMs)) return null; @@ -210,6 +218,33 @@ export function threadWokeAt( return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; } +/** A GitHub notice remains unread until a visit covers its receipt time, including on a new thread. */ +export function threadHasUnseenGitHubReply(shell: { + readonly githubReplyNotice?: Pick | null | undefined; + readonly lastVisitedAt?: string | null | undefined; +}): boolean { + const receivedAt = Date.parse(shell.githubReplyNotice?.receivedAt ?? ""); + if (Number.isNaN(receivedAt)) return false; + if (!shell.lastVisitedAt) return true; + const visitedAt = Date.parse(shell.lastVisitedAt); + return Number.isNaN(visitedAt) || receivedAt > visitedAt; +} + +/** Completions and GitHub wakes share the client's existing visited watermark. */ +export function threadHasUnseenActivity(shell: { + readonly latestRun?: Pick | null | undefined; + readonly githubReplyNotice?: Pick | null | undefined; + readonly lastVisitedAt?: string | null | undefined; +}): boolean { + if (threadHasUnseenGitHubReply(shell)) return true; + // Historical completions without visits stay read. + if (!shell.lastVisitedAt) return false; + const completedAt = Date.parse(shell.latestRun?.completedAt ?? ""); + if (Number.isNaN(completedAt)) return false; + const visitedAt = Date.parse(shell.lastVisitedAt); + return Number.isNaN(visitedAt) || completedAt > visitedAt; +} + const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 793d5a4a3a959..5a67e5c982406 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries. -import { ThreadId } from "@t3tools/contracts"; +import { CommandId, ThreadId } from "@t3tools/contracts"; import { TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -11,6 +11,8 @@ import { snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, + threadHasUnseenActivity, + threadHasUnseenGitHubReply, type ThreadSnoozeShell, } from "./threadSettled.ts"; @@ -367,3 +369,111 @@ describe("resolveSnoozePresets", () => { expect(tomorrow.getDay()).toBe(1); }); }); + +it("keeps a GitHub reply snooze through the registering agent's completion", () => { + const shell = { + ...makeShell({ turnCompletedAt: "2026-04-10T10:30:00.000Z" }), + snoozedAt: "2026-04-10T09:00:00.000Z", + githubReplySnooze: { + requestId: CommandId.make("github-watch"), + url: "https://github.com/team/repo/pull/1", + startedAt: "2026-04-10T09:00:00.000Z", + nextCheckAt: "2026-04-10T09:02:00.000Z", + status: "watching" as const, + failures: 0, + }, + }; + expect(effectiveSnoozed(shell, { now: NOW })).toBe(true); + expect(effectiveSnoozed({ ...shell, hasPendingApprovals: true }, { now: NOW })).toBe(false); + const notice = { + type: "reply" as const, + url: shell.githubReplySnooze.url, + receivedAt: NOW, + text: "Feedback", + author: "reviewer", + }; + expect( + threadWokeAt({ ...shell, githubReplySnooze: null, githubReplyNotice: notice }, { now: NOW }), + ).toBe(NOW); +}); + +describe("threadHasUnseenActivity", () => { + const completedAt = "2026-04-10T09:00:00.000Z"; + const receivedAt = "2026-04-10T11:00:00.000Z"; + it.each([null, { completedAt }])( + "counts a GitHub wake independently of the run: %j", + (latestRun) => { + const shell = { latestRun, githubReplyNotice: { receivedAt } }; + expect(threadHasUnseenActivity({ ...shell, lastVisitedAt: "2026-04-10T10:00:00.000Z" })).toBe( + true, + ); + expect(threadHasUnseenActivity({ ...shell, lastVisitedAt: receivedAt })).toBe(false); + expect(threadHasUnseenActivity({ ...shell, lastVisitedAt: NOW })).toBe(false); + }, + ); + + it.each([null, undefined])( + "counts a GitHub notice with no visit watermark: %s", + (lastVisitedAt) => { + expect( + threadHasUnseenActivity({ + latestRun: null, + lastVisitedAt, + githubReplyNotice: { receivedAt }, + }), + ).toBe(true); + expect( + threadHasUnseenActivity({ + latestRun: { completedAt }, + lastVisitedAt, + githubReplyNotice: { receivedAt }, + }), + ).toBe(true); + expect( + threadHasUnseenActivity({ + lastVisitedAt, + githubReplyNotice: { receivedAt: "invalid" }, + }), + ).toBe(false); + }, + ); + + it("keeps existing visit and malformed timestamp behavior", () => { + const shell = { latestRun: { completedAt }, githubReplyNotice: { receivedAt } }; + expect(threadHasUnseenActivity({ latestRun: { completedAt } })).toBe(false); + expect(threadHasUnseenActivity({ latestRun: { completedAt }, lastVisitedAt: null })).toBe( + false, + ); + expect(threadHasUnseenActivity({ ...shell, lastVisitedAt: "invalid" })).toBe(true); + expect( + threadHasUnseenActivity({ + ...shell, + lastVisitedAt: completedAt, + githubReplyNotice: { receivedAt: "invalid" }, + }), + ).toBe(false); + expect( + threadHasUnseenActivity({ + latestRun: { completedAt: "invalid" }, + githubReplyNotice: { receivedAt }, + lastVisitedAt: completedAt, + }), + ).toBe(true); + }); +}); + +it("keeps GitHub unread separate from a newer unseen completion", () => { + expect( + threadHasUnseenGitHubReply({ + githubReplyNotice: { receivedAt: "2026-04-10T09:00:00.000Z" }, + lastVisitedAt: "2026-04-10T10:00:00.000Z", + }), + ).toBe(false); + expect( + threadHasUnseenActivity({ + latestRun: { completedAt: "2026-04-10T11:00:00.000Z" }, + githubReplyNotice: { receivedAt: "2026-04-10T09:00:00.000Z" }, + lastVisitedAt: "2026-04-10T10:00:00.000Z", + }), + ).toBe(true); +}); diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index b9a46fa65287f..87e619be53139 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -369,6 +369,19 @@ export function summarizeT3ToolCalls( ); break; } + case "link-discussion": + label = phrase("Linked", "link", quantity(selected.length, "discussion")); + break; + case "unlink-discussion": + label = phrase("Unlinked", "unlink", quantity(selected.length, "discussion")); + break; + case "list-discussions": + label = phrase( + "Checked", + "check", + `linked discussions${selected.length === 1 ? "" : ` ${times}`}`, + ); + break; case "link-pr": label = phrase("Linked", "link", quantity(selected.length, "pull request")); break; diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 6e41a53478a1c..8ddcab86787e4 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -903,6 +903,37 @@ describe("pull request tool presentation", () => { }); }); +describe("discussion tool presentation", () => { + it("recognizes discussion calls across provider identities without claiming another integration", () => { + const entries = [ + { label: "mcp__t3_code__link_discussion" }, + { label: "T3-code · unlink_discussion" }, + { label: "MCP tool call", toolData: { server: "t3-code", tool: "list_thread_discussions" } }, + ].map((entry, index): WorkLogPresentationEntry => ({ + ...entry, + id: String(index), + createdAt: "2026-10-07T00:00:00.000Z", + tone: "tool", + itemType: "dynamic_tool", + toolLifecycleStatus: "completed", + })); + expect(entries.map((entry) => resolveWorkEntryToolPresentation(entry)?.icon)).toEqual([ + "discussion", + "discussion", + "discussion", + ]); + expect(toolGroupSummaryKind(entries)).toBe("discussion"); + expect(summarizeToolGroup([entries[0]!, entries[2]!]).summary).toBe( + "Linked 1 discussion and checked linked discussions", + ); + expect( + resolveWorkEntryToolPresentation({ + label: "mcp__another-server__link_discussion", + }), + ).toBeNull(); + }); +}); + describe("device group summaries", () => { const deviceEntry = (tool: string): WorkLogPresentationEntry => ({ id: tool, diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 8ef6f58be5954..f0ab473ccf451 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -102,6 +102,7 @@ export type ToolGroupAction = | "update"; export type ToolGroupSummaryKind = + | "discussion" | "pull-request" | ToolGroupAction | "dynamic-tool" @@ -739,6 +740,11 @@ export function toolGroupSummaryKind( entries.every((entry) => resolveWorkEntryToolPresentation(entry)?.icon === "pull-request") ) return "pull-request"; + if ( + entries.length > 0 && + entries.every((entry) => resolveWorkEntryToolPresentation(entry)?.icon === "discussion") + ) + return "discussion"; const actions = new Set(entries.map(toolGroupAction)); if (actions.size !== 1) return "mixed"; const action = actions.values().next().value!; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c9ac7482d4ccc..d4d2212949d62 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -135,6 +135,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + threadGitHubReplySnooze: Schema.optionalKey(Schema.Boolean), + threadGitHubChangesRequestedSnooze: Schema.optionalKey(Schema.Boolean), /** Server streams themes an environment publishes. Absent on servers from before environment themes shipped, which never emit the events -- so a client reconnecting to one must drop published themes rather than keep @@ -173,6 +175,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ shaping and validation when this is absent. */ serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean), threadPullRequests: Schema.optionalKey(Schema.Boolean), + threadDiscussions: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pull-request.watch and wakes agents on pull request changes. */ threadPullRequestWatch: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/githubReply.test.ts b/packages/contracts/src/githubReply.test.ts new file mode 100644 index 0000000000000..b993e6b09016d --- /dev/null +++ b/packages/contracts/src/githubReply.test.ts @@ -0,0 +1,53 @@ +import { CommandId } from "./baseSchemas.ts"; +import { GitHubReplyNotice, GitHubReplySnooze } from "./githubReply.ts"; +import { OrchestrationV2Command } from "./orchestrationV2.ts"; +import * as Schema from "effect/Schema"; +import { expect, it } from "vite-plus/test"; + +const decodeSnooze = Schema.decodeUnknownSync(GitHubReplySnooze); +const decodeNotice = Schema.decodeUnknownSync(GitHubReplyNotice); +const decodeCommand = Schema.decodeUnknownSync(OrchestrationV2Command); + +const legacy = { + requestId: CommandId.make("legacy-snooze"), + url: "https://github.com/team/repo/pull/1", + startedAt: "2026-10-01T12:00:00Z", + nextCheckAt: "2026-10-01T12:02:00Z", + status: "watching", + failures: 0, + baseline: { latestAt: null, ids: [] }, +}; + +it("keeps saved snoozes without a wake condition compatible and roundtrips the new condition", () => { + expect(decodeSnooze(legacy).wakeCondition).toBeUndefined(); + const saved = { ...legacy, wakeCondition: "changes-requested" }; + expect(decodeSnooze(saved).wakeCondition).toBe("changes-requested"); + expect(() => decodeSnooze({ ...legacy, wakeCondition: "negative-sentiment" })).toThrow(); + const notice = decodeNotice({ + type: "reply", + url: legacy.url, + receivedAt: legacy.startedAt, + text: "", + wakeCondition: "changes-requested", + }); + expect(notice.wakeCondition).toBe("changes-requested"); +}); + +it("rejects unsupported GitHub snooze URLs at the command boundary", () => { + const command = { + type: "thread.github-reply.snooze", + commandId: "snooze", + threadId: "thread", + wakeCondition: "changes-requested", + }; + for (const url of [ + "https://example.com/pr/1", + "https://github.com/team/repo/issues/1", + "https://ghe.example/team/repo/pull/1", + ]) { + expect(() => decodeCommand({ ...command, url })).toThrow(); + } + expect(decodeCommand({ ...command, url: legacy.url })).toMatchObject({ + wakeCondition: "changes-requested", + }); +}); diff --git a/packages/contracts/src/githubReply.ts b/packages/contracts/src/githubReply.ts new file mode 100644 index 0000000000000..2db6bac36549d --- /dev/null +++ b/packages/contracts/src/githubReply.ts @@ -0,0 +1,69 @@ +import * as Schema from "effect/Schema"; +import { CommandId, IsoDateTime } from "./baseSchemas.ts"; + +/** PR comments are flat; discussion anchors select one comment and its replies. */ +export function parseGitHubConversationUrl(value: string) { + const match = + /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(pull|discussions)\/([1-9]\d*)(?:#(issuecomment-\d+|discussioncomment-\d+|discussion_r\d+|pullrequestreview-\d+))?\/?$/.exec( + value, + ); + if (!match) return null; + const [, owner, repository, kind, number, anchor] = match; + if (kind === "pull" && anchor?.startsWith("discussioncomment-")) return null; + if (kind === "discussions" && anchor && !anchor.startsWith("discussioncomment-")) return null; + return { + owner: owner!, + repository: repository!, + kind: kind === "pull" ? ("pull" as const) : ("discussion" as const), + number: Number(number), + commentId: + kind === "discussions" && anchor ? Number(anchor.slice("discussioncomment-".length)) : null, + url: `https://github.com/${owner}/${repository}/${kind}/${number}${anchor ? `#${anchor}` : ""}`, + }; +} + +export const GitHubConversationUrl = Schema.String.pipe( + Schema.check( + Schema.makeFilter( + (value) => + parseGitHubConversationUrl(value) !== null || + "Use a github.com pull request or repository discussion URL.", + ), + ), +); + +export const GitHubReplyWakeCondition = Schema.Literals(["any-reply", "changes-requested"]); +export type GitHubReplyWakeCondition = typeof GitHubReplyWakeCondition.Type; + +export const GitHubReplySnooze = Schema.Struct({ + /** omitted on older snoozes, which wait for any reply. */ + wakeCondition: Schema.optional(GitHubReplyWakeCondition), + requestId: CommandId, + url: GitHubConversationUrl, + startedAt: IsoDateTime, + nextCheckAt: IsoDateTime, + status: Schema.Literals(["pending", "watching", "retrying", "rate-limited"]), + failures: Schema.Number, + viewer: Schema.optional(Schema.String), + discussionCommentId: Schema.optional(Schema.String), + /** GitHub's newest observed timestamp and all response IDs in that second. */ + baseline: Schema.optional( + Schema.Struct({ + latestAt: Schema.NullOr(IsoDateTime), + ids: Schema.Array(Schema.String), + }), + ), +}); +export type GitHubReplySnooze = typeof GitHubReplySnooze.Type; + +export const GitHubReplyNotice = Schema.Struct({ + wakeCondition: Schema.optional(GitHubReplyWakeCondition), + type: Schema.Literals(["reply", "error"]), + /** Original watched scope, distinct from the individual reply URL. */ + conversationUrl: Schema.optional(GitHubConversationUrl), + url: Schema.String, + receivedAt: IsoDateTime, + author: Schema.optional(Schema.String), + text: Schema.String, +}); +export type GitHubReplyNotice = typeof GitHubReplyNotice.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d738ef79909e2..81f658558948f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -63,4 +63,7 @@ export * from "./resourceTelemetry.ts"; export * from "./rpc.ts"; export * from "./worktreeSetup.ts"; export * from "./secretRequest.ts"; +export * from "./githubReply.ts"; + +export * from "./threadDiscussion.ts"; export * from "./clientRpcPermissions.ts"; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 7a1e56b6d5e32..d42e3a8e7f791 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1,3 +1,14 @@ +import { + ThreadDiscussionKey, + ThreadDiscussionLink, + GitHubDiscussionUrl, +} from "./threadDiscussion.ts"; +import { + GitHubConversationUrl, + GitHubReplySnooze, + GitHubReplyNotice, + GitHubReplyWakeCondition, +} from "./githubReply.ts"; import { OrchestrationMessageContext } from "./composerContext.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -376,6 +387,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ pre-linking servers still decode. */ linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), pullRequests: Schema.optional(Schema.Array(ThreadPullRequestLink)), + discussions: Schema.optional(Schema.Array(ThreadDiscussionLink)), /** Pull request discovered from the thread's current branch. */ branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), activeProviderThreadId: Schema.NullOr(ProviderThreadId), @@ -404,7 +416,10 @@ export const OrchestrationV2AppThread = Schema.Struct({ unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + githubReplySnooze: Schema.optional(Schema.NullOr(GitHubReplySnooze)), + githubReplyNotice: Schema.optional(Schema.NullOr(GitHubReplyNotice)), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), + githubReplyAutoResumeBlockedRunId: Schema.optional(Schema.NullOr(RunId)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), // Fractional-index slot in the user-arranged pinned order. Optional so @@ -1847,6 +1862,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ /** Pull request the user linked to this thread (#8160). */ linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), pullRequests: Schema.optional(Schema.Array(ThreadPullRequestLink)), + discussions: Schema.optional(Schema.Array(ThreadDiscussionLink)), /** Pull request discovered from the thread's current branch. */ branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), lineage: OrchestrationV2AppThreadLineage, @@ -1902,7 +1918,10 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + githubReplySnooze: Schema.optional(Schema.NullOr(GitHubReplySnooze)), + githubReplyNotice: Schema.optional(Schema.NullOr(GitHubReplyNotice)), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), + githubReplyAutoResumeBlockedRunId: Schema.optional(Schema.NullOr(RunId)), /** Omitted by servers that predate thread pinning. */ pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), @@ -1999,6 +2018,8 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields(( unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), + githubReplySnooze: Schema.optional(Schema.NullOr(GitHubReplySnooze)), + githubReplyNotice: Schema.optional(Schema.NullOr(GitHubReplyNotice)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe( @@ -2424,6 +2445,8 @@ export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFiel unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), + githubReplySnooze: Schema.optional(Schema.NullOr(GitHubReplySnooze)), + githubReplyNotice: Schema.optional(Schema.NullOr(GitHubReplyNotice)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), @@ -2672,6 +2695,15 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, reason: Schema.Literal("user"), }), + Schema.Struct({ + type: Schema.Literal("thread.github-reply.snooze"), + commandId: CommandId, + threadId: ThreadId, + url: GitHubConversationUrl, + wakeCondition: Schema.optional(GitHubReplyWakeCondition), + /** Undo restores the original observation baseline under a new subscription identity. */ + resumeFrom: Schema.optional(GitHubReplySnooze), + }), Schema.Struct({ type: Schema.Literal("thread.snooze"), commandId: CommandId, @@ -2749,9 +2781,25 @@ export const OrchestrationV2Command = Schema.Union([ /** Reject unless no message or run has landed on this thread. */ expectedEmpty: Schema.optional(Schema.Boolean), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecoveryUpdate)), + /** worker-prepared recovery must respect later github reply snoozes. */ + automaticLimitRecovery: Schema.optional(Schema.Literal(true)), /** Link (object) or unlink (null) a pull request (#8160); absent leaves it unchanged. */ linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), }), + Schema.Struct({ + type: Schema.Literal("thread.discussion.link"), + commandId: CommandId, + threadId: ThreadId, + discussion: Schema.Struct({ ...ThreadDiscussionKey.fields, url: GitHubDiscussionUrl }), + title: Schema.optional(TrimmedNonEmptyString), + source: ThreadDiscussionLink.fields.source, + }), + Schema.Struct({ + type: Schema.Literal("thread.discussion.unlink"), + commandId: CommandId, + threadId: ThreadId, + discussion: ThreadDiscussionKey, + }), Schema.Struct({ type: Schema.Literal("thread.pull-request.link"), commandId: CommandId, @@ -3060,6 +3108,14 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; * send them. */ const OrchestrationV2InternalCommand = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("thread.github-reply.sync"), + commandId: CommandId, + threadId: ThreadId, + requestId: CommandId, + watch: Schema.NullOr(GitHubReplySnooze), + notice: Schema.optional(GitHubReplyNotice), + }), /** * Records what a pull request watch saw, and wakes the agent in the same transaction when * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is diff --git a/packages/contracts/src/threadDiscussion.test.ts b/packages/contracts/src/threadDiscussion.test.ts new file mode 100644 index 0000000000000..e60f7d122cc97 --- /dev/null +++ b/packages/contracts/src/threadDiscussion.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import { + GitHubDiscussionUrl, + ThreadDiscussionKey, + ThreadDiscussionLink, +} from "./threadDiscussion.ts"; + +const decodeLink = Schema.decodeUnknownSync(ThreadDiscussionLink); + +describe("discussion contracts", () => { + it("accepts whole discussions and explicit comment scopes", () => { + const decode = Schema.decodeUnknownSync(GitHubDiscussionUrl); + expect(decode("https://github.com/team/repo/discussions/1")).toBe( + "https://github.com/team/repo/discussions/1", + ); + expect(decode("https://github.com/team/repo/discussions/1#discussioncomment-2")).toContain( + "#discussioncomment-2", + ); + }); + it("rejects other hosts, pull requests and malformed repositories", () => { + const decodeUrl = Schema.decodeUnknownSync(GitHubDiscussionUrl); + for (const url of [ + "https://github.example/team/repo/discussions/1", + "https://github.com/team/repo/pull/1", + "https://github.com/team/repo/discussions/0", + ]) + expect(() => decodeUrl(url)).toThrow(); + const decodeKey = Schema.decodeUnknownSync(ThreadDiscussionKey); + for (const repository of ["team", "team/repo/extra", "team/repo?query"]) + expect(() => decodeKey({ host: "github.com", repository, number: 1 })).toThrow(); + }); + it("retains the association without requiring PR state", () => { + const link = { + host: "github.com", + repository: "team/repo", + number: 1, + url: "https://github.com/team/repo/discussions/1", + title: null, + source: "agent", + linkedAt: "2026-10-07T12:00:00Z", + }; + expect(decodeLink(link)).toEqual(link); + }); +}); diff --git a/packages/contracts/src/threadDiscussion.ts b/packages/contracts/src/threadDiscussion.ts new file mode 100644 index 0000000000000..2b1dfb9710345 --- /dev/null +++ b/packages/contracts/src/threadDiscussion.ts @@ -0,0 +1,31 @@ +import * as Schema from "effect/Schema"; +import { IsoDateTime, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { parseGitHubConversationUrl } from "./githubReply.ts"; +export const GitHubDiscussionUrl = TrimmedNonEmptyString.check( + Schema.makeFilter( + (url) => + parseGitHubConversationUrl(url)?.kind === "discussion" || + "Use a github.com repository discussion URL.", + ), +); + +export const ThreadDiscussionKey = Schema.Struct({ + host: Schema.Literal("github.com"), + repository: TrimmedNonEmptyString.check(Schema.isPattern(/^[\w.-]+\/[\w.-]+$/)), + number: PositiveInt, +}); +export type ThreadDiscussionKey = typeof ThreadDiscussionKey.Type; + +export const ThreadDiscussionLink = Schema.Struct({ + ...ThreadDiscussionKey.fields, + url: GitHubDiscussionUrl, + title: Schema.NullOr(TrimmedNonEmptyString), + source: Schema.Literals(["manual", "agent"]), + linkedAt: IsoDateTime, +}); +export type ThreadDiscussionLink = typeof ThreadDiscussionLink.Type; + +export class DiscussionOperationError extends Schema.TaggedError()( + "DiscussionOperationError", + { message: Schema.String }, +) {} diff --git a/packages/provider-core/src/server/runtimeInstructions.ts b/packages/provider-core/src/server/runtimeInstructions.ts index a7b1c5e4c2e05..33a97423a187c 100644 --- a/packages/provider-core/src/server/runtimeInstructions.ts +++ b/packages/provider-core/src/server/runtimeInstructions.ts @@ -1,6 +1,10 @@ -const PULL_REQUEST_LINKING_INSTRUCTIONS = ` +const GITHUB_LINKING_INSTRUCTIONS = ` 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. 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. -`; + + + +When the t3-code MCP server exposes link_discussion, use it to register every GitHub discussion you create or comment on for this thread. Call link_discussion with its full discussion URL after creating or posting through gh or the GitHub API. Those operations do not register the discussion with T3 Code. Linking an already-linked discussion is safe. Before finishing discussion work, call list_thread_discussions and link any discussion from your work that is missing. Do not link unrelated discussions mentioned only as background. If linking fails, report that failure. Linking only saves an association; the user chooses whether to snooze until a reply. +`; /** * Shared runtime context; omit model and effort when the harness manages them dynamically. @@ -20,7 +24,7 @@ export function buildRuntimeInstructions(runtime: { modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model; const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; - return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; + return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${GITHUB_LINKING_INSTRUCTIONS}`; } function toSingleLine(value: string): string { diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index 7ee8d7c1aa8d9..5fc29e8857f30 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -59,6 +59,7 @@ export function makeSubagentChildThread(input: { title: input.title, linkedPullRequest: null, pullRequests: [], + discussions: [], historyOrigin: undefined, providerInstanceId: input.providerInstanceId, modelSelection: input.modelSelection, @@ -78,6 +79,8 @@ export function makeSubagentChildThread(input: { settledOverride: null, settledAt: null, snoozedUntil: null, + githubReplySnooze: null, + githubReplyNotice: null, snoozedAt: null, lastVisitedAt: null, deletedAt: null, diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 11abf77f704be..bc480ad10529a 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -53,6 +53,9 @@ export type T3McpToolSummaryAction = | "attachment-prepare" | "attachment-discard" | "attachment-send" + | "link-discussion" + | "unlink-discussion" + | "list-discussions" | "link-pr" | "unlink-pr" | "list-prs" @@ -66,7 +69,7 @@ export type T3McpToolSummaryAction = export interface T3McpToolDefinition { readonly displayName: string; readonly labels: readonly [action: string, running: string, completed: string, detail: string]; - readonly icon: "t3-code" | "browser" | "device" | "pull-request"; + readonly icon: "t3-code" | "browser" | "device" | "pull-request" | "discussion"; readonly summaryAction: T3McpToolSummaryAction; } @@ -83,6 +86,21 @@ const T3_MCP_SERVER_ALIASES = new Set(["t3-code", "t3_code", "t3code"]); // Cards, activity rows, summaries, and provider identity recovery share this inventory. const T3_MCP_TOOLS: Readonly> = { + link_discussion: tool( + ["Link", "Linking", "Linked", "a discussion"], + "link-discussion", + "discussion", + ), + unlink_discussion: tool( + ["Unlink", "Unlinking", "Unlinked", "a discussion"], + "unlink-discussion", + "discussion", + ), + list_thread_discussions: tool( + ["Check", "Checking", "Checked", "linked discussions"], + "list-discussions", + "discussion", + ), link_pull_request: tool( ["Link", "Linking", "Linked", "a pull request"], "link-pr", diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index d749272375348..dae9fe83d5628 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -6,7 +6,7 @@ import type { ThreadPullRequestLink, } from "@t3tools/contracts"; -import { pullRequestHostOf } from "@t3tools/contracts"; +import { parseGitHubConversationUrl, pullRequestHostOf } from "@t3tools/contracts"; import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts"; @@ -345,3 +345,21 @@ export function threadPullRequestsOf(thread: { }, ]; } + +/** changes-requested snoozes require a saved, visible PR confirmed open by its latest sync. */ +export function isLinkedOpenGitHubPullRequest( + links: ReadonlyArray, + url: string, +): boolean { + const target = parseGitHubConversationUrl(url); + if (target?.kind !== "pull") return false; + const canonicalUrl = target.url.split("#")[0]!.toLowerCase(); + return visibleThreadPullRequests(links).some( + (link) => + link.host.toLowerCase() === "github.com" && + link.snapshot?.state === "open" && + link.number === target.number && + link.repository.toLowerCase() === `${target.owner}/${target.repository}`.toLowerCase() && + parseGitHubConversationUrl(link.url)?.url.split("#")[0]?.toLowerCase() === canonicalUrl, + ); +}