- Preserve Codex turn identity while suppressing duplicate diff notifications - Optimize settlement projections and isolate thread visit handling - Add concurrency and regression coverage across server and mobile
Showing the changes to apps (53 of 167 files). Show all
Unified · Split
| @@ -0,0 +1,189 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // The registry runs on macOS without starting a simulator. Only the OS-facing | ||
| 4 | // types are replaced; the tests compile the dependency's actual Swift source. | ||
| 5 | public enum UIBackgroundFetchResult { | ||
| 6 | case noData | ||
| 7 | } | ||
| 8 | |||
| 9 | public struct UNNotificationPresentationOptions: OptionSet { | ||
| 10 | public let rawValue: Int | ||
| 11 | public init(rawValue: Int) { self.rawValue = rawValue } | ||
| 12 | } | ||
| 13 | |||
| 14 | public final class UNNotification: NSObject {} | ||
| 15 | |||
| 16 | public final class UNNotificationResponse: NSObject { | ||
| 17 | let identifier: String | ||
| 18 | init(_ identifier: String) { self.identifier = identifier } | ||
| 19 | } | ||
| 20 | |||
| 21 | public protocol UNUserNotificationCenterDelegate: AnyObject {} | ||
| 22 | |||
| 23 | public final class UNUserNotificationCenter: NSObject { | ||
| 24 | private static let instance = UNUserNotificationCenter() | ||
| 25 | public weak var delegate: UNUserNotificationCenterDelegate? | ||
| 26 | public static func current() -> UNUserNotificationCenter { instance } | ||
| 27 | } | ||
| 28 | |||
| 29 | private final class TestDelegate: NotificationDelegate { | ||
| 30 | private let lock = NSLock() | ||
| 31 | private var recordedEvents: [String] = [] | ||
| 32 | var onEvent: ((String) -> Void)? | ||
| 33 | var onResponse: ((UNNotificationResponse) -> Bool)? | ||
| 34 | |||
| 35 | var events: [String] { lock.withLock { recordedEvents } } | ||
| 36 | |||
| 37 | private func record(_ event: String) { | ||
| 38 | lock.withLock { recordedEvents.append(event) } | ||
| 39 | onEvent?(event) | ||
| 40 | } | ||
| 41 | |||
| 42 | func didRegister(_ deviceToken: String) { record("registered") } | ||
| 43 | func didFailRegistration(_ error: Error) { record("failed") } | ||
| 44 | func openSettings(_ notification: UNNotification?) { record("settings") } | ||
| 45 | |||
| 46 | func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { | ||
| 47 | record("present") | ||
| 48 | return false | ||
| 49 | } | ||
| 50 | |||
| 51 | func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { | ||
| 52 | record("background") | ||
| 53 | return false | ||
| 54 | } | ||
| 55 | |||
| 56 | func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { | ||
| 57 | record(response.identifier) | ||
| 58 | return onResponse?(response) ?? false | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { | ||
| 63 | if !condition() { | ||
| 64 | throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | private func deliver(_ identifier: String) { | ||
| 69 | NotificationCenterManager.shared.userNotificationCenter( | ||
| 70 | UNUserNotificationCenter.current(), | ||
| 71 | didReceive: UNNotificationResponse(identifier), | ||
| 72 | withCompletionHandler: {} | ||
| 73 | ) | ||
| 74 | } | ||
| 75 | |||
| 76 | private func reentrantCallbacks() throws { | ||
| 77 | let manager = NotificationCenterManager.shared | ||
| 78 | let center = UNUserNotificationCenter.current() | ||
| 79 | let callbacks: [(String, () -> Void)] = [ | ||
| 80 | ("registered", { manager.didRegister("token") }), | ||
| 81 | ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }), | ||
| 82 | ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }), | ||
| 83 | ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }), | ||
| 84 | ("background", { manager.didReceive([:], completionHandler: { _ in }) }) | ||
| 85 | ] | ||
| 86 | |||
| 87 | for (event, callback) in callbacks { | ||
| 88 | let original = TestDelegate() | ||
| 89 | let replacement = TestDelegate() | ||
| 90 | original.onEvent = { [weak original] _ in | ||
| 91 | guard let original else { return } | ||
| 92 | manager.removeDelegate(original) | ||
| 93 | manager.addDelegate(replacement) | ||
| 94 | } | ||
| 95 | manager.addDelegate(original) | ||
| 96 | callback() | ||
| 97 | try require(original.events == [event], "The original delegate must receive the callback once") | ||
| 98 | try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot") | ||
| 99 | callback() | ||
| 100 | try require(replacement.events == [event], "Reentrant registration must take effect for the next callback") | ||
| 101 | manager.removeDelegate(replacement) | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | private func registrationDuringDelivery() throws { | ||
| 106 | let manager = NotificationCenterManager.shared | ||
| 107 | let original = TestDelegate() | ||
| 108 | let receiver = TestDelegate() | ||
| 109 | receiver.onResponse = { _ in true } | ||
| 110 | original.onResponse = { _ in | ||
| 111 | manager.addDelegate(receiver) | ||
| 112 | return false | ||
| 113 | } | ||
| 114 | manager.addDelegate(original) | ||
| 115 | deliver("handoff") | ||
| 116 | try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response") | ||
| 117 | manager.removeDelegate(original) | ||
| 118 | manager.removeDelegate(receiver) | ||
| 119 | } | ||
| 120 | |||
| 121 | private func responseDuringReplay() throws { | ||
| 122 | let manager = NotificationCenterManager.shared | ||
| 123 | deliver("first") | ||
| 124 | let original = TestDelegate() | ||
| 125 | original.onResponse = { response in | ||
| 126 | if response.identifier == "first" { | ||
| 127 | deliver("second") | ||
| 128 | return true | ||
| 129 | } | ||
| 130 | return false | ||
| 131 | } | ||
| 132 | manager.addDelegate(original) | ||
| 133 | let receiver = TestDelegate() | ||
| 134 | receiver.onResponse = { _ in true } | ||
| 135 | manager.addDelegate(receiver) | ||
| 136 | try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks") | ||
| 137 | manager.removeDelegate(original) | ||
| 138 | manager.removeDelegate(receiver) | ||
| 139 | } | ||
| 140 | |||
| 141 | private func concurrentRegistrations() throws { | ||
| 142 | let manager = NotificationCenterManager.shared | ||
| 143 | let delegates = (0..<512).map { _ in | ||
| 144 | let delegate = TestDelegate() | ||
| 145 | delegate.onResponse = { _ in true } | ||
| 146 | return delegate | ||
| 147 | } | ||
| 148 | DispatchQueue.concurrentPerform(iterations: delegates.count) { index in | ||
| 149 | manager.addDelegate(delegates[index]) | ||
| 150 | if index.isMultiple(of: 16) { | ||
| 151 | manager.didRegister("during-add") | ||
| 152 | deliver("during-add") | ||
| 153 | } | ||
| 154 | } | ||
| 155 | let before = delegates.map { $0.events.count } | ||
| 156 | manager.didRegister("after-add") | ||
| 157 | for (index, delegate) in delegates.enumerated() { | ||
| 158 | try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once") | ||
| 159 | } | ||
| 160 | DispatchQueue.concurrentPerform(iterations: delegates.count) { index in | ||
| 161 | manager.removeDelegate(delegates[index]) | ||
| 162 | if index.isMultiple(of: 16) { | ||
| 163 | manager.didRegister("during-remove") | ||
| 164 | deliver("during-remove") | ||
| 165 | } | ||
| 166 | } | ||
| 167 | let removed = delegates.map { $0.events.count } | ||
| 168 | manager.didRegister("after-remove") | ||
| 169 | try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks") | ||
| 170 | } | ||
| 171 | |||
| 172 | @main | ||
| 173 | private enum RegressionTests { | ||
| 174 | static func main() { | ||
| 175 | do { | ||
| 176 | switch CommandLine.arguments.last { | ||
| 177 | case "reentrant": try reentrantCallbacks() | ||
| 178 | case "handoff": try registrationDuringDelivery() | ||
| 179 | case "pending": try responseDuringReplay() | ||
| 180 | case "concurrent": try concurrentRegistrations() | ||
| 181 | default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2) | ||
| 182 | } | ||
| 183 | print("passed") | ||
| 184 | } catch { | ||
| 185 | FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) | ||
| 186 | exit(1) | ||
| 187 | } | ||
| 188 | } | ||
| 189 | } | ||
| @@ -0,0 +1,65 @@ | |||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly. | ||
| 2 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | import * as NodeFS from "node:fs"; | ||
| 4 | import * as NodeOS from "node:os"; | ||
| 5 | import * as NodePath from "node:path"; | ||
| 6 | import * as NodeURL from "node:url"; | ||
| 7 | import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; | ||
| 8 | |||
| 9 | // oxlint-disable-next-line t3code/no-global-process-runtime -- This native integration test uses the host Swift compiler. | ||
| 10 | describe.skipIf(NodeOS.platform() !== "darwin")( | ||
| 11 | "NotificationCenterManager native concurrency", | ||
| 12 | () => { | ||
| 13 | let directory: string; | ||
| 14 | let executable: string; | ||
| 15 | |||
| 16 | beforeAll(() => { | ||
| 17 | directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-notifications-test-")); | ||
| 18 | executable = NodePath.join(directory, "notification-regression"); | ||
| 19 | const source = NodeFS.readFileSync( | ||
| 20 | new URL( | ||
| 21 | "../node_modules/expo-notifications/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift", | ||
| 22 | import.meta.url, | ||
| 23 | ), | ||
| 24 | "utf8", | ||
| 25 | ); | ||
| 26 | const manager = NodePath.join(directory, "NotificationCenterManager.swift"); | ||
| 27 | NodeFS.writeFileSync( | ||
| 28 | manager, | ||
| 29 | source.replace(/^import (ExpoModulesCore|UserNotifications)\n/gm, ""), | ||
| 30 | ); | ||
| 31 | NodeChildProcess.execFileSync( | ||
| 32 | "swiftc", | ||
| 33 | [ | ||
| 34 | "-swift-version", | ||
| 35 | "5", | ||
| 36 | "-sanitize=thread", | ||
| 37 | manager, | ||
| 38 | NodeURL.fileURLToPath( | ||
| 39 | new URL("./fixtures/NotificationCenterManagerRegression.swift", import.meta.url), | ||
| 40 | ), | ||
| 41 | "-o", | ||
| 42 | executable, | ||
| 43 | ], | ||
| 44 | { timeout: 30_000, encoding: "utf8" }, | ||
| 45 | ); | ||
| 46 | }); | ||
| 47 | |||
| 48 | afterAll(() => { | ||
| 49 | if (directory) NodeFS.rmSync(directory, { recursive: true, force: true }); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it.each([ | ||
| 53 | ["reentrant", "allows callbacks to replace delegates without deadlocking"], | ||
| 54 | ["handoff", "delivers responses to delegates registering during delivery"], | ||
| 55 | ["pending", "retains new responses received while replaying pending responses"], | ||
| 56 | ["concurrent", "registers, removes, and broadcasts concurrently without data races"], | ||
| 57 | ])("%s: %s", (name) => { | ||
| 58 | const output = NodeChildProcess.execFileSync(executable, [name], { | ||
| 59 | encoding: "utf8", | ||
| 60 | timeout: 15_000, | ||
| 61 | }); | ||
| 62 | expect(output.trim()).toBe("passed"); | ||
| 63 | }); | ||
| 64 | }, | ||
| 65 | ); | ||
| @@ -923,7 +923,7 @@ export const make = Effect.gen(function* () { · 922 unmodified lines | |||
| 923 | } satisfies GitVcsDriver.GitStatusDetails; | 923 | } satisfies GitVcsDriver.GitStatusDetails; |
| 924 | const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { | 924 | const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { |
| 925 | const details = yield* gitCore | 925 | const details = yield* gitCore |
| 926 | .statusDetailsLocal(cwd) | 926 | .statusDetailsLocal(cwd, { includeDivergence: false }) |
| 927 | .pipe( | 927 | .pipe( |
| 928 | Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), | 928 | Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), |
| 929 | ); | 929 | ); |
| @@ -1083,7 +1083,10 @@ function codexReplayPreamble(input: { · 1082 unmodified lines | |||
| 1083 | method: "initialize", | 1083 | method: "initialize", |
| 1084 | params: { | 1084 | params: { |
| 1085 | clientInfo: { name: "t3code_desktop", title: "T3 Code Desktop", version: "0.1.0" }, | 1085 | clientInfo: { name: "t3code_desktop", title: "T3 Code Desktop", version: "0.1.0" }, |
| 1086 | capabilities: { experimentalApi: true }, | 1086 | capabilities: { |
| 1087 | experimentalApi: true, | ||
| 1088 | optOutNotificationMethods: ["turn/diff/updated"], | ||
| 1089 | }, | ||
| 1087 | }, | 1090 | }, |
| 1088 | }, | 1091 | }, |
| 1089 | }, | 1092 | }, |
| @@ -173,6 +173,7 @@ const CODEX_CLIENT_INFO = { · 172 unmodified lines | |||
| 173 | } as const; | 173 | } as const; |
| 174 | const CODEX_CLIENT_CAPABILITIES = { | 174 | const CODEX_CLIENT_CAPABILITIES = { |
| 175 | experimentalApi: true, | 175 | experimentalApi: true, |
| 176 | optOutNotificationMethods: ["turn/diff/updated"], | ||
| 176 | } as const; | 177 | } as const; |
| 177 | 178 | ||
| 178 | export const CodexProviderCapabilitiesV2 = { | 179 | export const CodexProviderCapabilitiesV2 = { |
| @@ -1386,6 +1386,49 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1385 unmodified lines | |||
| 1386 | }); | 1386 | }); |
| 1387 | }); | 1387 | }); |
| 1388 | 1388 | ||
| 1389 | const dispatchThreadVisit = Effect.fn("orchestrationV2.dispatch.threadVisit")(function* ( | ||
| 1390 | command: Extract<OrchestrationV2Command, { readonly type: "thread.visit" }>, | ||
| 1391 | events: Ref.Ref<Array<OrchestrationV2DomainEvent>>, | ||
| 1392 | ) { | ||
| 1393 | const thread = yield* projectionStore | ||
| 1394 | .getThread(command.threadId) | ||
| 1395 | .pipe( | ||
| 1396 | Effect.mapError( | ||
| 1397 | (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), | ||
| 1398 | ), | ||
| 1399 | ); | ||
| 1400 | if (thread.deletedAt !== null) { | ||
| 1401 | return yield* new OrchestratorDispatchError({ | ||
| 1402 | commandId: command.commandId, | ||
| 1403 | commandType: command.type, | ||
| 1404 | cause: `Thread ${command.threadId} is deleted.`, | ||
| 1405 | }); | ||
| 1406 | } | ||
| 1407 | const visitedAt = DateTime.make(command.visitedAt); | ||
| 1408 | if (Option.isNone(visitedAt)) { | ||
| 1409 | return yield* new OrchestratorDispatchError({ | ||
| 1410 | commandId: command.commandId, | ||
| 1411 | commandType: command.type, | ||
| 1412 | cause: `Thread ${command.threadId} visit time ${command.visitedAt} is not a valid timestamp.`, | ||
| 1413 | }); | ||
| 1414 | } | ||
| 1415 | const movesForward = | ||
| 1416 | thread.lastVisitedAt === null || | ||
| 1417 | DateTime.toEpochMillis(visitedAt.value) > DateTime.toEpochMillis(thread.lastVisitedAt); | ||
| 1418 | // Viewing a thread changes read state only. Loading its transcript (or | ||
| 1419 | // bumping updatedAt) makes a routine read receipt scale with its history. | ||
| 1420 | yield* emit( | ||
| 1421 | events, | ||
| 1422 | command, | ||
| 1423 | )({ | ||
| 1424 | type: "thread.visited", | ||
| 1425 | threadId: command.threadId, | ||
| 1426 | providerInstanceId: thread.providerInstanceId, | ||
| 1427 | occurredAt: yield* DateTime.now, | ||
| 1428 | payload: movesForward ? { ...thread, lastVisitedAt: visitedAt.value } : thread, | ||
| 1429 | }); | ||
| 1430 | }); | ||
| 1431 | |||
| 1389 | const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* ( | 1432 | const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* ( |
| 1390 | command: Extract< | 1433 | command: Extract< |
| 1391 | OrchestrationV2Command, | 1434 | OrchestrationV2Command, |
| @@ -1401,7 +1444,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 9 unmodified lines | |||
| 1401 | | "thread.pin" | 1444 | | "thread.pin" |
| 1402 | | "thread.unpin" | 1445 | | "thread.unpin" |
| 1403 | | "thread.pin.reorder" | 1446 | | "thread.pin.reorder" |
| 1404 | | "thread.visit" | ||
| 1405 | | "thread.mark-unread" | 1447 | | "thread.mark-unread" |
| 1406 | | "thread.metadata.update" | 1448 | | "thread.metadata.update" |
| 1407 | | "thread.title.regeneration.complete" | 1449 | | "thread.title.regeneration.complete" |
| @@ -1548,18 +1590,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 140 unmodified lines | |||
| 1548 | } | 1590 | } |
| 1549 | snoozedUntil = parsedSnoozedUntil.value; | 1591 | snoozedUntil = parsedSnoozedUntil.value; |
| 1550 | } | 1592 | } |
| 1551 | let visitedAt: DateTime.Utc | null = null; | ||
| 1552 | if (command.type === "thread.visit") { | ||
| 1553 | const parsedVisitedAt = DateTime.make(command.visitedAt); | ||
| 1554 | if (Option.isNone(parsedVisitedAt)) { | ||
| 1555 | return yield* new OrchestratorDispatchError({ | ||
| 1556 | commandId: command.commandId, | ||
| 1557 | commandType: command.type, | ||
| 1558 | cause: `Thread ${command.threadId} visit time ${command.visitedAt} is not a valid timestamp.`, | ||
| 1559 | }); | ||
| 1560 | } | ||
| 1561 | visitedAt = parsedVisitedAt.value; | ||
| 1562 | } | ||
| 1563 | let markUnreadVisitedAt: DateTime.Utc | null = null; | 1593 | let markUnreadVisitedAt: DateTime.Utc | null = null; |
| 1564 | if (command.type === "thread.mark-unread") { | 1594 | if (command.type === "thread.mark-unread") { |
| 1565 | const latestRunCompletedAt = projection.runs.at(-1)?.completedAt ?? null; | 1595 | const latestRunCompletedAt = projection.runs.at(-1)?.completedAt ?? null; |
| @@ -1678,17 +1708,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 112 unmodified lines | |||
| 1678 | updatedAt: keyUnchanged ? thread.updatedAt : now, | 1708 | updatedAt: keyUnchanged ? thread.updatedAt : now, |
| 1679 | }; | 1709 | }; |
| 1680 | } | 1710 | } |
| 1681 | // Visited tracking records read state only; it must not bump updatedAt, | ||
| 1682 | // otherwise viewing a thread would count as activity (and re-trigger the | ||
| 1683 | // clients that dispatch thread.visit whenever updatedAt advances). | ||
| 1684 | case "thread.visit": { | ||
| 1685 | const previousVisitedAt = thread.lastVisitedAt; | ||
| 1686 | const movesForward = | ||
| 1687 | visitedAt !== null && | ||
| 1688 | (previousVisitedAt === null || | ||
| 1689 | DateTime.toEpochMillis(visitedAt) > DateTime.toEpochMillis(previousVisitedAt)); | ||
| 1690 | return movesForward ? { ...thread, lastVisitedAt: visitedAt } : thread; | ||
| 1691 | } | ||
| 1692 | case "thread.mark-unread": | 1711 | case "thread.mark-unread": |
| 1693 | return { ...thread, lastVisitedAt: markUnreadVisitedAt }; | 1712 | return { ...thread, lastVisitedAt: markUnreadVisitedAt }; |
| 1694 | case "thread.metadata.update": | 1713 | case "thread.metadata.update": |
| @@ -1751,8 +1770,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 56 unmodified lines | |||
| 1751 | return "thread.unpinned" as const; | 1770 | return "thread.unpinned" as const; |
| 1752 | case "thread.pin.reorder": | 1771 | case "thread.pin.reorder": |
| 1753 | return "thread.pin-reordered" as const; | 1772 | return "thread.pin-reordered" as const; |
| 1754 | case "thread.visit": | ||
| 1755 | return "thread.visited" as const; | ||
| 1756 | case "thread.mark-unread": | 1773 | case "thread.mark-unread": |
| 1757 | return "thread.marked-unread" as const; | 1774 | return "thread.marked-unread" as const; |
| 1758 | case "thread.metadata.update": | 1775 | case "thread.metadata.update": |
| @@ -6921,16 +6938,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 5162 unmodified lines | |||
| 6921 | case "thread.create": | 6938 | case "thread.create": |
| 6922 | yield* dispatchThreadCreate(command, events); | 6939 | yield* dispatchThreadCreate(command, events); |
| 6923 | break; | 6940 | break; |
| 6941 | case "thread.visit": | ||
| 6942 | yield* dispatchThreadVisit(command, events); | ||
| 6943 | break; | ||
| 6924 | case "thread.auto-settle": { | 6944 | case "thread.auto-settle": { |
| 6925 | // Automatic settlement (#8600): the sweep evaluated a shell snapshot, | 6945 | // Automatic settlement (#8600): the sweep evaluated a shell snapshot, |
| 6926 | // so re-check against the live thread before settling. Any change | 6946 | // so re-check against the live thread before settling. Any change |
| 6927 | // after the snapshot — or any explicit override, including the | 6947 | // after the snapshot — or any explicit override, including the |
| 6928 | // un-settle button's "active" — wins over the sweep. | 6948 | // un-settle button's "active" — wins over the sweep. |
| 6929 | const projection = yield* loadProjectionForCommand(command); | 6949 | const thread = yield* projectionStore |
| 6950 | .getThread(command.threadId) | ||
| 6951 | .pipe( | ||
| 6952 | Effect.mapError( | ||
| 6953 | (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), | ||
| 6954 | ), | ||
| 6955 | ); | ||
| 6930 | if ( | 6956 | if ( |
| 6931 | projection.thread.settledOverride !== null || | 6957 | thread.settledOverride !== null || |
| 6932 | DateTime.toEpochMillis(projection.thread.updatedAt) > | 6958 | DateTime.toEpochMillis(thread.updatedAt) > DateTime.toEpochMillis(command.snapshotAt) |
| 6933 | DateTime.toEpochMillis(command.snapshotAt) | ||
| 6934 | ) { | 6959 | ) { |
| 6935 | return yield* new OrchestratorDispatchError({ | 6960 | return yield* new OrchestratorDispatchError({ |
| 6936 | commandId: command.commandId, | 6961 | commandId: command.commandId, |
| @@ -6960,7 +6985,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 23 unmodified lines | |||
| 6960 | case "thread.pin": | 6985 | case "thread.pin": |
| 6961 | case "thread.unpin": | 6986 | case "thread.unpin": |
| 6962 | case "thread.pin.reorder": | 6987 | case "thread.pin.reorder": |
| 6963 | case "thread.visit": | ||
| 6964 | case "thread.mark-unread": | 6988 | case "thread.mark-unread": |
| 6965 | case "thread.metadata.update": | 6989 | case "thread.metadata.update": |
| 6966 | case "thread.title.regeneration.complete": | 6990 | case "thread.title.regeneration.complete": |
| @@ -0,0 +1,363 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | EventId, | ||
| 4 | MessageId, | ||
| 5 | NodeId, | ||
| 6 | type OrchestrationV2AppThread, | ||
| 7 | type OrchestrationV2Run, | ||
| 8 | ProjectId, | ||
| 9 | ProviderDriverKind, | ||
| 10 | ProviderInstanceId, | ||
| 11 | ProviderThreadId, | ||
| 12 | RunId, | ||
| 13 | RuntimeRequestId, | ||
| 14 | ThreadId, | ||
| 15 | TurnItemId, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import * as DateTime from "effect/DateTime"; | ||
| 18 | import * as Effect from "effect/Effect"; | ||
| 19 | import * as Layer from "effect/Layer"; | ||
| 20 | import * as Schema from "effect/Schema"; | ||
| 21 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 22 | import * as Statement from "effect/unstable/sql/Statement"; | ||
| 23 | |||
| 24 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | ||
| 25 | import { | ||
| 26 | ProjectionStoreV2, | ||
| 27 | layer as projectionStoreLayer, | ||
| 28 | layerMemory, | ||
| 29 | } from "./ProjectionStore.ts"; | ||
| 30 | import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; | ||
| 31 | |||
| 32 | const SqlLayer = projectionStoreLayer.pipe(Layer.provideMerge(SqlitePersistenceMemory)); | ||
| 33 | const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); | ||
| 34 | const old = DateTime.subtract(now, { days: 10 }); | ||
| 35 | const providerInstanceId = ProviderInstanceId.make("codex"); | ||
| 36 | const modelSelection = { instanceId: providerInstanceId, model: "test-model" }; | ||
| 37 | const encodeNumbers = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.Number))); | ||
| 38 | |||
| 39 | const createThread = Effect.fn(function* ( | ||
| 40 | name: string, | ||
| 41 | overrides: Partial<OrchestrationV2AppThread> = {}, | ||
| 42 | ) { | ||
| 43 | const store = yield* ProjectionStoreV2; | ||
| 44 | const threadId = ThreadId.make(`thread:settlement:${name}`); | ||
| 45 | const thread: OrchestrationV2AppThread = { | ||
| 46 | createdBy: "user", | ||
| 47 | creationSource: "web", | ||
| 48 | id: threadId, | ||
| 49 | projectId: ProjectId.make("project:settlement"), | ||
| 50 | title: name, | ||
| 51 | providerInstanceId, | ||
| 52 | modelSelection, | ||
| 53 | runtimeMode: "full-access", | ||
| 54 | interactionMode: "default", | ||
| 55 | branch: "feature", | ||
| 56 | worktreePath: null, | ||
| 57 | activeProviderThreadId: null, | ||
| 58 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | ||
| 59 | forkedFrom: null, | ||
| 60 | createdAt: old, | ||
| 61 | updatedAt: old, | ||
| 62 | archivedAt: null, | ||
| 63 | settledOverride: null, | ||
| 64 | settledAt: null, | ||
| 65 | lastVisitedAt: null, | ||
| 66 | deletedAt: null, | ||
| 67 | ...overrides, | ||
| 68 | }; | ||
| 69 | yield* store.apply({ | ||
| 70 | id: EventId.make(`event:${threadId}`), | ||
| 71 | type: "thread.created", | ||
| 72 | threadId, | ||
| 73 | occurredAt: old, | ||
| 74 | payload: thread, | ||
| 75 | }); | ||
| 76 | return threadId; | ||
| 77 | }); | ||
| 78 | |||
| 79 | const createRun = Effect.fn(function* ( | ||
| 80 | threadId: ThreadId, | ||
| 81 | status: OrchestrationV2Run["status"] = "completed", | ||
| 82 | ordinal = 1, | ||
| 83 | ) { | ||
| 84 | const store = yield* ProjectionStoreV2; | ||
| 85 | const runId = RunId.make(`run:${threadId}:${ordinal}`); | ||
| 86 | yield* store.apply({ | ||
| 87 | id: EventId.make(`event:${runId}`), | ||
| 88 | type: "run.created", | ||
| 89 | threadId, | ||
| 90 | occurredAt: old, | ||
| 91 | payload: { | ||
| 92 | id: runId, | ||
| 93 | threadId, | ||
| 94 | ordinal, | ||
| 95 | providerInstanceId, | ||
| 96 | modelSelection, | ||
| 97 | providerThreadId: null, | ||
| 98 | userMessageId: MessageId.make(`message:${runId}`), | ||
| 99 | rootNodeId: null, | ||
| 100 | activeAttemptId: null, | ||
| 101 | status, | ||
| 102 | requestedAt: old, | ||
| 103 | startedAt: status === "queued" ? null : old, | ||
| 104 | completedAt: ["completed", "rolled_back", "failed"].includes(status) ? old : null, | ||
| 105 | checkpointId: null, | ||
| 106 | contextHandoffId: null, | ||
| 107 | }, | ||
| 108 | }); | ||
| 109 | return runId; | ||
| 110 | }); | ||
| 111 | |||
| 112 | const createItem = Effect.fn(function* ( | ||
| 113 | threadId: ThreadId, | ||
| 114 | runId: RunId, | ||
| 115 | status: "idle" | "running" | "completed", | ||
| 116 | persistent = false, | ||
| 117 | ) { | ||
| 118 | const store = yield* ProjectionStoreV2; | ||
| 119 | yield* store.apply({ | ||
| 120 | id: EventId.make(`event:item:${runId}`), | ||
| 121 | type: "turn-item.updated", | ||
| 122 | threadId, | ||
| 123 | occurredAt: old, | ||
| 124 | payload: { | ||
| 125 | id: TurnItemId.make(`item:${runId}`), | ||
| 126 | threadId, | ||
| 127 | runId, | ||
| 128 | nodeId: null, | ||
| 129 | providerThreadId: null, | ||
| 130 | providerTurnId: null, | ||
| 131 | nativeItemRef: null, | ||
| 132 | parentItemId: null, | ||
| 133 | ordinal: 1, | ||
| 134 | type: "dynamic_tool", | ||
| 135 | status, | ||
| 136 | title: null, | ||
| 137 | startedAt: old, | ||
| 138 | completedAt: status === "completed" ? old : null, | ||
| 139 | updatedAt: old, | ||
| 140 | toolName: "background", | ||
| 141 | input: persistent ? { persistent: true } : null, | ||
| 142 | output: null, | ||
| 143 | }, | ||
| 144 | }); | ||
| 145 | }); | ||
| 146 | |||
| 147 | for (const [name, testLayer] of [ | ||
| 148 | ["sql", SqlLayer], | ||
| 149 | ["memory", layerMemory], | ||
| 150 | ] as const) { | ||
| 151 | it.effect( | ||
| 152 | `${name}: discovers settlement work with the same activity and background semantics as the shell`, | ||
| 153 | () => | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const store = yield* ProjectionStoreV2; | ||
| 156 | const idle = yield* createThread("idle"); | ||
| 157 | const completed = yield* createThread("completed"); | ||
| 158 | yield* createRun(completed); | ||
| 159 | for (const [name, overrides] of [ | ||
| 160 | ["archived", { archivedAt: old }], | ||
| 161 | ["deleted", { deletedAt: old }], | ||
| 162 | ["settled", { settledOverride: "settled" }], | ||
| 163 | ["unsettled", { settledOverride: "active" }], | ||
| 164 | ["pinned", { pinnedAt: old }], | ||
| 165 | ] satisfies ReadonlyArray<readonly [string, Partial<OrchestrationV2AppThread>]>) { | ||
| 166 | yield* createRun(yield* createThread(name, overrides)); | ||
| 167 | } | ||
| 168 | for (const status of ["preparing", "starting", "running", "waiting"] as const) { | ||
| 169 | yield* createRun(yield* createThread(status), status); | ||
| 170 | } | ||
| 171 | const queued = yield* createThread("queued"); | ||
| 172 | yield* createRun(queued, "queued"); | ||
| 173 | const blocked = yield* createThread("blocked"); | ||
| 174 | yield* store.apply({ | ||
| 175 | id: EventId.make("event:settlement:request"), | ||
| 176 | type: "runtime-request.updated", | ||
| 177 | threadId: blocked, | ||
| 178 | occurredAt: old, | ||
| 179 | payload: { | ||
| 180 | id: RuntimeRequestId.make("request:settlement"), | ||
| 181 | nodeId: NodeId.make("node:settlement"), | ||
| 182 | providerTurnId: null, | ||
| 183 | nativeRequestRef: null, | ||
| 184 | kind: "user_input", | ||
| 185 | status: "pending", | ||
| 186 | responseCapability: { type: "not_resumable", reason: "Process stopped" }, | ||
| 187 | createdAt: old, | ||
| 188 | resolvedAt: null, | ||
| 189 | }, | ||
| 190 | }); | ||
| 191 | const recent = yield* createThread("recent-message"); | ||
| 192 | yield* createRun(recent); | ||
| 193 | yield* store.apply({ | ||
| 194 | id: EventId.make("event:settlement:message"), | ||
| 195 | type: "message.updated", | ||
| 196 | threadId: recent, | ||
| 197 | occurredAt: now, | ||
| 198 | payload: { | ||
| 199 | createdBy: "user", | ||
| 200 | creationSource: "web", | ||
| 201 | id: MessageId.make("message:settlement:recent"), | ||
| 202 | threadId: recent, | ||
| 203 | runId: null, | ||
| 204 | nodeId: null, | ||
| 205 | role: "user", | ||
| 206 | text: "Next turn", | ||
| 207 | attachments: [], | ||
| 208 | streaming: false, | ||
| 209 | createdAt: now, | ||
| 210 | updatedAt: now, | ||
| 211 | }, | ||
| 212 | }); | ||
| 213 | yield* createRun( | ||
| 214 | yield* createThread("snoozed", { | ||
| 215 | snoozedUntil: DateTime.add(now, { days: 1 }), | ||
| 216 | snoozedAt: now, | ||
| 217 | }), | ||
| 218 | ); | ||
| 219 | const woke = yield* createThread("woke", { | ||
| 220 | snoozedUntil: DateTime.add(now, { days: 1 }), | ||
| 221 | snoozedAt: DateTime.subtract(old, { days: 1 }), | ||
| 222 | }); | ||
| 223 | yield* createRun(woke); | ||
| 224 | const background = yield* createThread("idle-background"); | ||
| 225 | yield* createItem(background, yield* createRun(background), "idle"); | ||
| 226 | const persistent = yield* createThread("persistent-monitor"); | ||
| 227 | yield* createItem(persistent, yield* createRun(persistent), "running", true); | ||
| 228 | const rolledBack = yield* createThread("rolled-back-background"); | ||
| 229 | yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running"); | ||
| 230 | yield* createRun(rolledBack, "completed", 2); | ||
| 231 | const roster = yield* createThread("provider-roster"); | ||
| 232 | yield* createRun(roster); | ||
| 233 | yield* store.apply({ | ||
| 234 | id: EventId.make("event:settlement:roster"), | ||
| 235 | type: "provider-thread.updated", | ||
| 236 | threadId: roster, | ||
| 237 | occurredAt: old, | ||
| 238 | payload: { | ||
| 239 | id: ProviderThreadId.make("provider-thread:settlement"), | ||
| 240 | appThreadId: roster, | ||
| 241 | ownerNodeId: null, | ||
| 242 | driver: ProviderDriverKind.make("codex"), | ||
| 243 | providerInstanceId, | ||
| 244 | providerSessionId: null, | ||
| 245 | nativeThreadRef: null, | ||
| 246 | nativeConversationHeadRef: null, | ||
| 247 | status: "idle", | ||
| 248 | firstRunOrdinal: null, | ||
| 249 | lastRunOrdinal: null, | ||
| 250 | handoffIds: [], | ||
| 251 | forkedFrom: null, | ||
| 252 | createdAt: old, | ||
| 253 | updatedAt: old, | ||
| 254 | pendingBackgroundTasks: [{ taskId: "running-task" }], | ||
| 255 | }, | ||
| 256 | }); | ||
| 257 | const candidates = yield* store.getSettlementCandidates(); | ||
| 258 | const shell = yield* store.getShellSnapshot({ location: "active" }); | ||
| 259 | const eligible = candidates.filter((thread) => | ||
| 260 | isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)), | ||
| 261 | ); | ||
| 262 | assert.deepEqual( | ||
| 263 | new Set(eligible.map((thread) => thread.id)), | ||
| 264 | new Set([idle, completed, queued, woke, persistent, rolledBack]), | ||
| 265 | ); | ||
| 266 | assert.deepEqual( | ||
| 267 | new Set(eligible.map((thread) => thread.id)), | ||
| 268 | new Set( | ||
| 269 | shell.threads | ||
| 270 | .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now))) | ||
| 271 | .map((thread) => thread.id), | ||
| 272 | ), | ||
| 273 | ); | ||
| 274 | for (const candidate of candidates) { | ||
| 275 | const expected = shell.threads.find((thread) => thread.id === candidate.id)!; | ||
| 276 | assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks); | ||
| 277 | const settings = { | ||
| 278 | pullRequest: null, | ||
| 279 | nowMs: DateTime.toEpochMillis(now), | ||
| 280 | autoSettleAfterDays: 7, | ||
| 281 | autoSettleOnMerge: false, | ||
| 282 | }; | ||
| 283 | assert.deepEqual( | ||
| 284 | resolveAutoSettlementAt({ ...settings, thread: candidate }), | ||
| 285 | resolveAutoSettlementAt({ ...settings, thread: expected }), | ||
| 286 | ); | ||
| 287 | } | ||
| 288 | assert.equal( | ||
| 289 | DateTime.formatIso((yield* store.getThread(completed)).createdAt), | ||
| 290 | DateTime.formatIso(old), | ||
| 291 | ); | ||
| 292 | assert.equal( | ||
| 293 | (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag, | ||
| 294 | "ProjectionStoreThreadNotFoundError", | ||
| 295 | ); | ||
| 296 | }).pipe(Effect.provide(testLayer)), | ||
| 297 | ); | ||
| 298 | } | ||
| 299 | |||
| 300 | it.effect( | ||
| 301 | "reads settlement candidates and thread metadata without loading historical or archived payloads", | ||
| 302 | () => | ||
| 303 | Effect.gen(function* () { | ||
| 304 | const store = yield* ProjectionStoreV2; | ||
| 305 | const sql = yield* SqlClient.SqlClient; | ||
| 306 | const source = yield* createThread("historical-source", { archivedAt: old }); | ||
| 307 | const sourceRun = yield* createRun(source); | ||
| 308 | const threadId = yield* createThread("fork", { | ||
| 309 | forkedFrom: { type: "run", threadId: source, runId: sourceRun }, | ||
| 310 | }); | ||
| 311 | const runId = yield* createRun(threadId); | ||
| 312 | yield* createItem(threadId, runId, "completed"); | ||
| 313 | yield* sql`UPDATE orchestration_v2_projection_turn_items SET payload_json = '{broken' WHERE thread_id = ${threadId}`; | ||
| 314 | yield* sql`UPDATE orchestration_v2_projection_runs SET payload_json = '{broken' WHERE thread_id = ${source}`; | ||
| 315 | yield* sql`INSERT INTO orchestration_v2_projection_turn_items | ||
| 316 | SELECT 'history:' || value, i.thread_id, i.run_id, i.node_id, i.provider_thread_id, i.provider_turn_id, | ||
| 317 | i.parent_item_id, i.ordinal + value, i.type, i.status, i.updated_at, i.payload_json | ||
| 318 | FROM orchestration_v2_projection_turn_items i, json_each(${encodeNumbers(Array.from({ length: 2000 }, (_, i) => i + 1))}) | ||
| 319 | WHERE i.thread_id = ${threadId}`; | ||
| 320 | const queries: Array<readonly [string, ReadonlyArray<unknown>]> = []; | ||
| 321 | const record: Statement.Transformer = (statement) => | ||
| 322 | Effect.sync(() => { | ||
| 323 | queries.push(statement.compile()); | ||
| 324 | return statement; | ||
| 325 | }); | ||
| 326 | const candidates = yield* store | ||
| 327 | .getSettlementCandidates() | ||
| 328 | .pipe(Effect.provideService(Statement.CurrentTransformer, record)); | ||
| 329 | assert.deepEqual( | ||
| 330 | candidates.map((thread) => thread.id), | ||
| 331 | [threadId], | ||
| 332 | ); | ||
| 333 | assert.isTrue(queries.every(([query]) => !query.includes("COUNT("))); | ||
| 334 | const pendingQuery = queries.find(([query]) => | ||
| 335 | query.includes("FROM orchestration_v2_projection_turn_items"), | ||
| 336 | ); | ||
| 337 | assert.isDefined(pendingQuery); | ||
| 338 | const metadata = yield* store.getThread(threadId); | ||
| 339 | assert.equal(metadata.id, threadId); | ||
| 340 | assert.equal((yield* store.getThreadProjection(threadId).pipe(Effect.exit))._tag, "Failure"); | ||
| 341 | const plan = yield* sql.unsafe<{ readonly detail: string }>( | ||
| 342 | `EXPLAIN QUERY PLAN ${pendingQuery![0]}`, | ||
| 343 | pendingQuery![1], | ||
| 344 | ); | ||
| 345 | assert.isTrue(plan.some((row) => row.detail.includes("turn_items_shell_pending_idx"))); | ||
| 346 | const countsPlan = yield* sql<{ readonly detail: string }>`EXPLAIN QUERY PLAN | ||
| 347 | SELECT thread_id, run_id, COUNT(*) FROM orchestration_v2_projection_turn_items | ||
| 348 | WHERE thread_id = ${threadId} AND run_id IS NOT NULL GROUP BY thread_id, run_id`; | ||
| 349 | assert.isTrue( | ||
| 350 | countsPlan.some((row) => | ||
| 351 | row.detail.includes( | ||
| 352 | "COVERING INDEX orchestration_v2_projection_turn_items_thread_run_idx", | ||
| 353 | ), | ||
| 354 | ), | ||
| 355 | ); | ||
| 356 | assert.isFalse(countsPlan.some((row) => row.detail.includes("TEMP B-TREE"))); | ||
| 357 | const messagePlan = yield* sql<{ readonly detail: string }>`EXPLAIN QUERY PLAN | ||
| 358 | SELECT updated_at FROM orchestration_v2_projection_messages | ||
| 359 | WHERE thread_id = ${threadId} AND role = 'user' ORDER BY updated_at DESC, message_id DESC LIMIT 1`; | ||
| 360 | assert.isTrue(messagePlan.some((row) => row.detail.includes("messages_latest_user_idx"))); | ||
| 361 | assert.isFalse(messagePlan.some((row) => row.detail.includes("TEMP B-TREE"))); | ||
| 362 | }).pipe(Effect.provide(SqlLayer)), | ||
| 363 | ); | ||
| @@ -1,4 +1,5 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | OrchestrationV2AppThread, | ||
| 2 | OrchestrationV2ConversationMessage, | 3 | OrchestrationV2ConversationMessage, |
| 3 | OrchestrationV2DomainEvent, | 4 | OrchestrationV2DomainEvent, |
| 4 | OrchestrationV2ProjectedTurnItem, | 5 | OrchestrationV2ProjectedTurnItem, |
| @@ -106,6 +107,32 @@ export type ProjectionRecoveryKind = · 101 unmodified lines | |||
| 106 | | "subagent-results" | 107 | | "subagent-results" |
| 107 | | "delegated-completions"; | 108 | | "delegated-completions"; |
| 108 | 109 | ||
| 110 | /** Thread activity needed by settlement, without transcript or fork history. */ | ||
| 111 | export type ProjectionSettlementCandidate = Pick< | ||
| 112 | OrchestrationV2ThreadShell, | ||
| 113 | | "id" | ||
| 114 | | "projectId" | ||
| 115 | | "branch" | ||
| 116 | | "worktreePath" | ||
| 117 | | "linkedPullRequest" | ||
| 118 | | "createdAt" | ||
| 119 | | "updatedAt" | ||
| 120 | | "archivedAt" | ||
| 121 | | "settledOverride" | ||
| 122 | | "pinnedAt" | ||
| 123 | | "snoozedUntil" | ||
| 124 | | "snoozedAt" | ||
| 125 | | "latestRunId" | ||
| 126 | | "latestRunRequestedAt" | ||
| 127 | | "latestRunStartedAt" | ||
| 128 | | "latestRunCompletedAt" | ||
| 129 | | "latestUserMessageAt" | ||
| 130 | | "status" | ||
| 131 | | "activityRunStatus" | ||
| 132 | | "pendingRuntimeRequest" | ||
| 133 | | "pendingBackgroundTasks" | ||
| 134 | >; | ||
| 135 | |||
| 109 | const ProjectionCheckpointContext = Schema.Struct({ | 136 | const ProjectionCheckpointContext = Schema.Struct({ |
| 110 | runs: Schema.Array( | 137 | runs: Schema.Array( |
| 111 | OrchestrationV2RunJsonSchema.mapFields(({ id, ordinal, status }) => ({ id, ordinal, status })), | 138 | OrchestrationV2RunJsonSchema.mapFields(({ id, ordinal, status }) => ({ id, ordinal, status })), |
| @@ -143,6 +170,13 @@ export interface ProjectionStoreV2Shape { · 31 unmodified lines | |||
| 143 | readonly getThreadShell: ( | 170 | readonly getThreadShell: ( |
| 144 | threadId: ThreadId, | 171 | threadId: ThreadId, |
| 145 | ) => Effect.Effect<OrchestrationV2ThreadShell | null, ProjectionStoreV2Error>; | 172 | ) => Effect.Effect<OrchestrationV2ThreadShell | null, ProjectionStoreV2Error>; |
| 173 | readonly getThread: ( | ||
| 174 | threadId: ThreadId, | ||
| 175 | ) => Effect.Effect<OrchestrationV2AppThread, ProjectionStoreV2Error>; | ||
| 176 | readonly getSettlementCandidates: () => Effect.Effect< | ||
| 177 | ReadonlyArray<ProjectionSettlementCandidate>, | ||
| 178 | ProjectionStoreV2Error | ||
| 179 | >; | ||
| 146 | readonly getThreadProjection: ( | 180 | readonly getThreadProjection: ( |
| 147 | threadId: ThreadId, | 181 | threadId: ThreadId, |
| 148 | ) => Effect.Effect<OrchestrationV2ThreadProjection, ProjectionStoreV2Error>; | 182 | ) => Effect.Effect<OrchestrationV2ThreadProjection, ProjectionStoreV2Error>; |
| @@ -585,6 +619,18 @@ type ShellRunRow = { · 436 unmodified lines | |||
| 585 | readonly ordinal: number; | 619 | readonly ordinal: number; |
| 586 | }; | 620 | }; |
| 587 | 621 | ||
| 622 | type SettlementThreadRow = Pick< | ||
| 623 | ShellThreadRow, | ||
| 624 | | "thread_id" | ||
| 625 | | "payload_json" | ||
| 626 | | "latest_run_id" | ||
| 627 | | "latest_run_status" | ||
| 628 | | "latest_run_requested_at" | ||
| 629 | | "latest_run_started_at" | ||
| 630 | | "latest_run_completed_at" | ||
| 631 | | "latest_user_message_at" | ||
| 632 | >; | ||
| 633 | |||
| 588 | type ShellRunItemCountRow = { | 634 | type ShellRunItemCountRow = { |
| 589 | readonly thread_id: string; | 635 | readonly thread_id: string; |
| 590 | readonly run_id: string; | 636 | readonly run_id: string; |
| @@ -2954,6 +3000,24 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 2363 unmodified lines | |||
| 2954 | const getThreadProjection: ProjectionStoreV2Shape["getThreadProjection"] = (threadId) => | 3000 | const getThreadProjection: ProjectionStoreV2Shape["getThreadProjection"] = (threadId) => |
| 2955 | readProjection(threadId, new Set()); | 3001 | readProjection(threadId, new Set()); |
| 2956 | 3002 | ||
| 3003 | const getThread: ProjectionStoreV2Shape["getThread"] = (threadId) => | ||
| 3004 | Effect.gen(function* () { | ||
| 3005 | const rows = yield* sql<PayloadRow>` | ||
| 3006 | SELECT payload_json FROM orchestration_v2_projection_threads | ||
| 3007 | WHERE thread_id = ${threadId} | ||
| 3008 | `; | ||
| 3009 | if (rows[0] === undefined) { | ||
| 3010 | return yield* new ProjectionStoreThreadNotFoundError({ threadId }); | ||
| 3011 | } | ||
| 3012 | return yield* decodeThreadPayload(rows[0].payload_json); | ||
| 3013 | }).pipe( | ||
| 3014 | Effect.mapError((cause) => | ||
| 3015 | isProjectionStoreThreadNotFoundError(cause) | ||
| 3016 | ? cause | ||
| 3017 | : new ProjectionStoreReadError({ threadId, cause }), | ||
| 3018 | ), | ||
| 3019 | ); | ||
| 3020 | |||
| 2957 | const getCheckpointContext: ProjectionStoreV2Shape["getCheckpointContext"] = (threadId) => | 3021 | const getCheckpointContext: ProjectionStoreV2Shape["getCheckpointContext"] = (threadId) => |
| 2958 | sql | 3022 | sql |
| 2959 | .withTransaction( | 3023 | .withTransaction( |
| @@ -3320,6 +3384,104 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 360 unmodified lines | |||
| 3320 | return { providerThreadsByThreadId, pendingTurnItemsByThreadId }; | 3384 | return { providerThreadsByThreadId, pendingTurnItemsByThreadId }; |
| 3321 | }); | 3385 | }); |
| 3322 | 3386 | ||
| 3387 | const getSettlementCandidates: ProjectionStoreV2Shape["getSettlementCandidates"] = () => | ||
| 3388 | sql | ||
| 3389 | .withTransaction( | ||
| 3390 | Effect.gen(function* () { | ||
| 3391 | // Settlement needs activity timestamps, not item counts, run history, | ||
| 3392 | // provider sessions or inherited fork rows. Reject parked and busy | ||
| 3393 | // threads before loading the remaining candidates' background work. | ||
| 3394 | const rows = yield* sql<SettlementThreadRow>` | ||
| 3395 | SELECT t.thread_id, t.payload_json, | ||
| 3396 | r.run_id AS latest_run_id, | ||
| 3397 | r.status AS latest_run_status, | ||
| 3398 | r.requested_at AS latest_run_requested_at, | ||
| 3399 | json_extract(r.payload_json, '$.startedAt') AS latest_run_started_at, | ||
| 3400 | r.completed_at AS latest_run_completed_at, | ||
| 3401 | ( | ||
| 3402 | SELECT message.updated_at | ||
| 3403 | FROM orchestration_v2_projection_messages message | ||
| 3404 | WHERE message.thread_id = t.thread_id AND message.role = 'user' | ||
| 3405 | ORDER BY message.updated_at DESC, message.message_id DESC | ||
| 3406 | LIMIT 1 | ||
| 3407 | ) AS latest_user_message_at | ||
| 3408 | FROM orchestration_v2_projection_threads t | ||
| 3409 | LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = ( | ||
| 3410 | SELECT latest.run_id FROM orchestration_v2_projection_runs latest | ||
| 3411 | WHERE latest.thread_id = t.thread_id | ||
| 3412 | ORDER BY latest.ordinal DESC, latest.run_id DESC | ||
| 3413 | LIMIT 1 | ||
| 3414 | ) | ||
| 3415 | WHERE t.deleted_at IS NULL | ||
| 3416 | AND json_extract(t.payload_json, '$.archivedAt') IS NULL | ||
| 3417 | AND json_extract(t.payload_json, '$.settledOverride') IS NULL | ||
| 3418 | AND json_extract(t.payload_json, '$.pinnedAt') IS NULL | ||
| 3419 | AND NOT EXISTS ( | ||
| 3420 | SELECT 1 FROM orchestration_v2_projection_runs active | ||
| 3421 | WHERE active.thread_id = t.thread_id | ||
| 3422 | AND active.status IN ('preparing', 'starting', 'running', 'waiting') | ||
| 3423 | ) | ||
| 3424 | AND NOT EXISTS ( | ||
| 3425 | SELECT 1 FROM orchestration_v2_projection_runtime_requests request | ||
| 3426 | WHERE request.thread_id = t.thread_id AND request.status = 'pending' | ||
| 3427 | ) | ||
| 3428 | ORDER BY t.updated_at ASC, t.thread_id ASC | ||
| 3429 | `; | ||
| 3430 | if (rows.length === 0) return []; | ||
| 3431 | const threadIds = rows.map((row) => ThreadId.make(row.thread_id)); | ||
| 3432 | const { providerThreadsByThreadId, pendingTurnItemsByThreadId } = | ||
| 3433 | yield* pendingBackgroundDataByThreadId({ | ||
| 3434 | providerThreadRows: yield* selectShellProviderThreadRows(threadIds), | ||
| 3435 | pendingTurnItemRows: yield* selectShellPendingTurnItemRows(threadIds), | ||
| 3436 | }); | ||
| 3437 | return yield* Effect.forEach(rows, (row) => | ||
| 3438 | Effect.gen(function* () { | ||
| 3439 | const thread = yield* decodeThreadPayload(row.payload_json); | ||
| 3440 | const status = shellStatusFromStoredRunStatus(row.latest_run_status); | ||
| 3441 | const latestRunId = | ||
| 3442 | row.latest_run_id === null ? null : RunId.make(row.latest_run_id); | ||
| 3443 | return { | ||
| 3444 | ...thread, | ||
| 3445 | pinnedAt: thread.pinnedAt ?? null, | ||
| 3446 | snoozedUntil: thread.snoozedUntil ?? null, | ||
| 3447 | snoozedAt: thread.snoozedAt ?? null, | ||
| 3448 | status, | ||
| 3449 | latestRunId, | ||
| 3450 | latestRunRequestedAt: | ||
| 3451 | row.latest_run_requested_at === null | ||
| 3452 | ? null | ||
| 3453 | : DateTime.makeUnsafe(row.latest_run_requested_at), | ||
| 3454 | latestRunStartedAt: | ||
| 3455 | row.latest_run_started_at === null | ||
| 3456 | ? null | ||
| 3457 | : DateTime.makeUnsafe(row.latest_run_started_at), | ||
| 3458 | latestRunCompletedAt: | ||
| 3459 | row.latest_run_completed_at === null | ||
| 3460 | ? null | ||
| 3461 | : DateTime.makeUnsafe(row.latest_run_completed_at), | ||
| 3462 | latestUserMessageAt: | ||
| 3463 | row.latest_user_message_at === null | ||
| 3464 | ? null | ||
| 3465 | : DateTime.makeUnsafe(row.latest_user_message_at), | ||
| 3466 | activityRunStatus: null, | ||
| 3467 | pendingRuntimeRequest: null, | ||
| 3468 | pendingBackgroundTasks: derivePendingBackgroundWork({ | ||
| 3469 | latestRun: | ||
| 3470 | latestRunId === null || status === "idle" | ||
| 3471 | ? null | ||
| 3472 | : { id: latestRunId, ordinal: 0, status }, | ||
| 3473 | providerThreads: providerThreadsByThreadId.get(thread.id) ?? [], | ||
| 3474 | turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], | ||
| 3475 | activeProviderThreadId: thread.activeProviderThreadId, | ||
| 3476 | hasActiveRun: false, | ||
| 3477 | }), | ||
| 3478 | } satisfies ProjectionSettlementCandidate; | ||
| 3479 | }), | ||
| 3480 | ); | ||
| 3481 | }), | ||
| 3482 | ) | ||
| 3483 | .pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); | ||
| 3484 | |||
| 3323 | const shellThreadStateFromRow = (input: { | 3485 | const shellThreadStateFromRow = (input: { |
| 3324 | readonly row: ShellThreadRow; | 3486 | readonly row: ShellThreadRow; |
| 3325 | readonly runOrdinalsByThreadId: ReadonlyMap<ThreadId, Map<RunId, number>>; | 3487 | readonly runOrdinalsByThreadId: ReadonlyMap<ThreadId, Map<RunId, number>>; |
| @@ -3566,6 +3728,8 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 240 unmodified lines | |||
| 3566 | apply, | 3728 | apply, |
| 3567 | getShellSnapshot, | 3729 | getShellSnapshot, |
| 3568 | getThreadShell, | 3730 | getThreadShell, |
| 3731 | getThread, | ||
| 3732 | getSettlementCandidates, | ||
| 3569 | getThreadProjection, | 3733 | getThreadProjection, |
| 3570 | getCheckpointContext, | 3734 | getCheckpointContext, |
| 3571 | getRecoveryThreadIds, | 3735 | getRecoveryThreadIds, |
| @@ -3638,6 +3802,34 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( · 66 unmodified lines | |||
| 3638 | .pipe(Effect.map(threadShellFromProjection)); | 3802 | .pipe(Effect.map(threadShellFromProjection)); |
| 3639 | return shell.deletedAt === null ? shell : null; | 3803 | return shell.deletedAt === null ? shell : null; |
| 3640 | }), | 3804 | }), |
| 3805 | getThread: (threadId) => | ||
| 3806 | Effect.gen(function* () { | ||
| 3807 | const projection = (yield* Ref.get(replayState)).projections.get(threadId); | ||
| 3808 | if (projection === undefined) { | ||
| 3809 | return yield* new ProjectionStoreThreadNotFoundError({ threadId }); | ||
| 3810 | } | ||
| 3811 | return projection.thread; | ||
| 3812 | }), | ||
| 3813 | getSettlementCandidates: () => | ||
| 3814 | Effect.gen(function* () { | ||
| 3815 | const projections = (yield* Ref.get(replayState)).projections; | ||
| 3816 | return [...projections.values()] | ||
| 3817 | .filter( | ||
| 3818 | ({ thread, runs, runtimeRequests }) => | ||
| 3819 | thread.deletedAt === null && | ||
| 3820 | thread.archivedAt === null && | ||
| 3821 | thread.settledOverride === null && | ||
| 3822 | thread.pinnedAt == null && | ||
| 3823 | !runs.some(isActivityRunForShell) && | ||
| 3824 | !runtimeRequests.some((request) => request.status === "pending"), | ||
| 3825 | ) | ||
| 3826 | .map(threadShellFromProjection) | ||
| 3827 | .toSorted( | ||
| 3828 | (left, right) => | ||
| 3829 | DateTime.toEpochMillis(left.updatedAt) - DateTime.toEpochMillis(right.updatedAt) || | ||
| 3830 | left.id.localeCompare(right.id), | ||
| 3831 | ); | ||
| 3832 | }), | ||
| 3641 | getRecoveryThreadIds: (kind) => | 3833 | getRecoveryThreadIds: (kind) => |
| 3642 | Effect.gen(function* () { | 3834 | Effect.gen(function* () { |
| 3643 | const projections = (yield* Ref.get(replayState)).projections; | 3835 | const projections = (yield* Ref.get(replayState)).projections; |
| @@ -216,6 +216,8 @@ it.effect( · 215 unmodified lines | |||
| 216 | apply: () => Effect.void, | 216 | apply: () => Effect.void, |
| 217 | getShellSnapshot: () => Effect.die("unused getShellSnapshot"), | 217 | getShellSnapshot: () => Effect.die("unused getShellSnapshot"), |
| 218 | getThreadShell: () => Effect.die("unused getThreadShell"), | 218 | getThreadShell: () => Effect.die("unused getThreadShell"), |
| 219 | getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), | ||
| 220 | getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"), | ||
| 219 | getThreadProjection: () => Ref.get(projection), | 221 | getThreadProjection: () => Ref.get(projection), |
| 220 | getCheckpointContext: () => Effect.die("not used"), | 222 | getCheckpointContext: () => Effect.die("not used"), |
| 221 | getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"), | 223 | getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"), |
| @@ -34,6 +34,7 @@ import { ServerActivation } from "../serverActivation.ts"; · 33 unmodified lines | |||
| 34 | import { ServerSettingsService } from "../serverSettings.ts"; | 34 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 35 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | 35 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 36 | import { OrchestratorV2, type OrchestratorV2Shape } from "./Orchestrator.ts"; | 36 | import { OrchestratorV2, type OrchestratorV2Shape } from "./Orchestrator.ts"; |
| 37 | import { ProjectionStoreV2 } from "./ProjectionStore.ts"; | ||
| 37 | import * as ThreadSettlementService from "./ThreadSettlementService.ts"; | 38 | import * as ThreadSettlementService from "./ThreadSettlementService.ts"; |
| 38 | 39 | ||
| 39 | import { | 40 | import { |
| @@ -417,12 +418,15 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: · 377 unmodified lines | |||
| 417 | getProjectShellsWithoutEnrichment: () => | 418 | getProjectShellsWithoutEnrichment: () => |
| 418 | Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)), | 419 | Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)), |
| 419 | }), | 420 | }), |
| 420 | Layer.mock(OrchestratorV2)({ | 421 | Layer.mock(ProjectionStoreV2)({ |
| 421 | getShellSnapshot: () => | 422 | getSettlementCandidates: () => |
| 422 | Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( | 423 | Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( |
| 423 | Effect.tap((count) => Queue.offer(snapshotReads, count)), | 424 | Effect.tap((count) => Queue.offer(snapshotReads, count)), |
| 424 | Effect.andThen(Ref.get(snapshots)), | 425 | Effect.andThen(Ref.get(snapshots)), |
| 426 | Effect.map((snapshot) => snapshot.threads), | ||
| 425 | ), | 427 | ), |
| 428 | }), | ||
| 429 | Layer.mock(OrchestratorV2)({ | ||
| 426 | dispatch, | 430 | dispatch, |
| 427 | }), | 431 | }), |
| 428 | Layer.mock(GitManager)({ | 432 | Layer.mock(GitManager)({ |
| @@ -17,6 +17,7 @@ import * as ServerSettings from "../serverSettings.ts"; · 16 unmodified lines | |||
| 17 | import { forkParked } from "../serverActivation.ts"; | 17 | import { forkParked } from "../serverActivation.ts"; |
| 18 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | 18 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 19 | import { OrchestratorV2 } from "./Orchestrator.ts"; | 19 | import { OrchestratorV2 } from "./Orchestrator.ts"; |
| 20 | import { ProjectionStoreV2, type ProjectionSettlementCandidate } from "./ProjectionStore.ts"; | ||
| 20 | 21 | ||
| 21 | export interface SettlementPullRequest { | 22 | export interface SettlementPullRequest { |
| 22 | readonly state: "open" | "closed" | "merged"; | 23 | readonly state: "open" | "closed" | "merged"; |
| @@ -94,7 +95,7 @@ function pullRequestSettles( · 71 unmodified lines | |||
| 94 | 95 | ||
| 95 | /** Cheap checks that run before any source control lookup. */ | 96 | /** Cheap checks that run before any source control lookup. */ |
| 96 | export function isAutoSettlementCandidate( | 97 | export function isAutoSettlementCandidate( |
| 97 | thread: OrchestrationV2ThreadShell, | 98 | thread: ProjectionSettlementCandidate, |
| 98 | nowMs: number, | 99 | nowMs: number, |
| 99 | ): boolean { | 100 | ): boolean { |
| 100 | if (thread.archivedAt !== null || thread.settledOverride !== null) return false; | 101 | if (thread.archivedAt !== null || thread.settledOverride !== null) return false; |
| @@ -118,7 +119,7 @@ export function isAutoSettlementCandidate( · 17 unmodified lines | |||
| 118 | } | 119 | } |
| 119 | 120 | ||
| 120 | export function resolveAutoSettlementAt(input: { | 121 | export function resolveAutoSettlementAt(input: { |
| 121 | readonly thread: OrchestrationV2ThreadShell; | 122 | readonly thread: ProjectionSettlementCandidate; |
| 122 | readonly pullRequest: SettlementPullRequest | null; | 123 | readonly pullRequest: SettlementPullRequest | null; |
| 123 | readonly nowMs: number; | 124 | readonly nowMs: number; |
| 124 | readonly autoSettleAfterDays: number | null; | 125 | readonly autoSettleAfterDays: number | null; |
| @@ -151,6 +152,7 @@ export class ThreadSettlementServiceV2 extends Context.Service< · 26 unmodified lines | |||
| 151 | 152 | ||
| 152 | export const make = Effect.gen(function* () { | 153 | export const make = Effect.gen(function* () { |
| 153 | const orchestrator = yield* OrchestratorV2; | 154 | const orchestrator = yield* OrchestratorV2; |
| 155 | const projections = yield* ProjectionStoreV2; | ||
| 154 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | 156 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 155 | const settingsService = yield* ServerSettings.ServerSettingsService; | 157 | const settingsService = yield* ServerSettings.ServerSettingsService; |
| 156 | const git = yield* GitManager.GitManager; | 158 | const git = yield* GitManager.GitManager; |
| @@ -161,7 +163,7 @@ export const make = Effect.gen(function* () { · 4 unmodified lines | |||
| 161 | const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( | 163 | const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( |
| 162 | mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, | 164 | mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, |
| 163 | ) { | 165 | ) { |
| 164 | const snapshot = yield* orchestrator.getShellSnapshot(); | 166 | const threads = yield* projections.getSettlementCandidates(); |
| 165 | const projectShells = yield* snapshots.getProjectShellsWithoutEnrichment(); | 167 | const projectShells = yield* snapshots.getProjectShellsWithoutEnrichment(); |
| 166 | const nowMs = DateTime.toEpochMillis(yield* DateTime.now); | 168 | const nowMs = DateTime.toEpochMillis(yield* DateTime.now); |
| 167 | const projects = new Map(projectShells.map((project) => [project.id, project])); | 169 | const projects = new Map(projectShells.map((project) => [project.id, project])); |
| @@ -169,9 +171,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line | |||
| 169 | // the merged pull request: most threads carry no link and settle from | 171 | // the merged pull request: most threads carry no link and settle from |
| 170 | // their branch lookup, which would otherwise wait for the next minute's | 172 | // their branch lookup, which would otherwise wait for the next minute's |
| 171 | // sweep on a possibly stale cached answer. | 173 | // sweep on a possibly stale cached answer. |
| 172 | const candidates = snapshot.threads.filter((thread) => | 174 | const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs)); |
| 173 | isAutoSettlementCandidate(thread, nowMs), | ||
| 174 | ); | ||
| 175 | // Use the same cwd as the sidebar so both paths share GitManager's PR cache. | 175 | // Use the same cwd as the sidebar so both paths share GitManager's PR cache. |
| 176 | const lookupCwdByThreadId = new Map<string, string>(); | 176 | const lookupCwdByThreadId = new Map<string, string>(); |
| 177 | yield* Effect.forEach( | 177 | yield* Effect.forEach( |
| @@ -1634,6 +1634,38 @@ it.layer(SharedApplicationDataPlaneTestLayer)("visited projection", (it) => { · 1633 unmodified lines | |||
| 1634 | }) | 1634 | }) |
| 1635 | .pipe(Effect.flip); | 1635 | .pipe(Effect.flip); |
| 1636 | assert.instanceOf(markUnread, OrchestratorDispatchError); | 1636 | assert.instanceOf(markUnread, OrchestratorDispatchError); |
| 1637 | |||
| 1638 | // A read receipt must not decode any transcript, including inherited or | ||
| 1639 | // unreadable historical rows. It only advances the thread's watermark. | ||
| 1640 | const sql = yield* SqlClient.SqlClient; | ||
| 1641 | yield* sql`INSERT INTO orchestration_v2_projection_turn_items ( | ||
| 1642 | turn_item_id, thread_id, ordinal, type, status, updated_at, payload_json | ||
| 1643 | ) VALUES ( | ||
| 1644 | 'item:visited:unreadable-history', ${threadId}, 1, 'dynamic_tool', 'completed', | ||
| 1645 | ${visitedAt}, '{broken' | ||
| 1646 | )`; | ||
| 1647 | const nextVisitedAt = "2026-07-24T02:00:00.000Z"; | ||
| 1648 | yield* orchestrator.dispatch({ | ||
| 1649 | type: "thread.visit", | ||
| 1650 | commandId: CommandId.make("runtime-layer-visited-without-history"), | ||
| 1651 | threadId, | ||
| 1652 | visitedAt: nextVisitedAt, | ||
| 1653 | }); | ||
| 1654 | const [watermark] = yield* sql<{ readonly visited_at: string; readonly updated_at: string }>` | ||
| 1655 | SELECT json_extract(payload_json, '$.lastVisitedAt') AS visited_at, updated_at | ||
| 1656 | FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId} | ||
| 1657 | `; | ||
| 1658 | assert.equal(watermark!.visited_at, nextVisitedAt); | ||
| 1659 | assert.equal(watermark!.updated_at, DateTime.formatIso(createdUpdatedAt)); | ||
| 1660 | const invalidVisit = yield* orchestrator | ||
| 1661 | .dispatch({ | ||
| 1662 | type: "thread.visit", | ||
| 1663 | commandId: CommandId.make("runtime-layer-visited-invalid-timestamp"), | ||
| 1664 | threadId, | ||
| 1665 | visitedAt: "invalid-timestamp", | ||
| 1666 | }) | ||
| 1667 | .pipe(Effect.flip); | ||
| 1668 | assert.instanceOf(invalidVisit, OrchestratorDispatchError); | ||
| 1637 | }), | 1669 | }), |
| 1638 | ); | 1670 | ); |
| 1639 | }); | 1671 | }); |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.128.0","scenario":"message_steering","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"message_steering.ndjson","description":"One active turn receives an immediate turn/steer request."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.128.0","scenario":"message_steering","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"message_steering.ndjson","description":"One active turn receives an immediate turn/steer request."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.128.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex_p","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.128.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex_p","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"multi_turn","metadata":{"source":"codex-app-server-probe","fileName":"multi_turn.ndjson","description":"One thread with two sequential user turns."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"multi_turn","metadata":{"source":"codex-app-server-probe","fileName":"multi_turn.ndjson","description":"One thread with two sequential user turns."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"plan_questions","metadata":{"source":"codex-app-server-probe","fileName":"plan_questions.ndjson","description":"One plan-mode turn that asks a structured clarifying question."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"plan_questions","metadata":{"source":"codex-app-server-probe","fileName":"plan_questions.ndjson","description":"One plan-mode turn that asks a structured clarifying question."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"proposed_plan","metadata":{"source":"codex-app-server-probe","fileName":"proposed_plan.ndjson","description":"One plan-mode turn that emits a proposed plan document."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"proposed_plan","metadata":{"source":"codex-app-server-probe","fileName":"proposed_plan.ndjson","description":"One plan-mode turn that emits a proposed plan document."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"provider_thread_resume","metadata":{"source":"codex-app-server-probe","fileName":"provider_thread_resume.ndjson","description":"One provider-native thread is started, completed, then resumed by thread id in a fresh app-server session."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"provider_thread_resume","metadata":{"source":"codex-app-server-probe","fileName":"provider_thread_resume.ndjson","description":"One provider-native thread is started, completed, then resumed by thread id in a fresh app-server session."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -33,7 +33,7 @@ · 27 unmodified lines | |||
| 33 | {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","status":{"type":"idle"}}}} | 33 | {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","status":{"type":"idle"}}}} |
| 34 | {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","turn":{"id":"019db117-b519-7fa3-85c8-7762b26478a7","items":[],"status":"completed","error":null,"startedAt":1776792614,"completedAt":1776792622,"durationMs":8794}}}} | 34 | {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","turn":{"id":"019db117-b519-7fa3-85c8-7762b26478a7","items":[],"status":"completed","error":null,"startedAt":1776792614,"completedAt":1776792622,"durationMs":8794}}}} |
| 35 | {"type":"runtime_exit","status":"success"} | 35 | {"type":"runtime_exit","status":"success"} |
| 36 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 36 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 37 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 37 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 38 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 38 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 39 | {"type":"expect_outbound","label":"thread/resume","frame":{"id":2,"method":"thread/resume","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","excludeTurns":true}}} | 39 | {"type":"expect_outbound","label":"thread/resume","frame":{"id":2,"method":"thread/resume","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","excludeTurns":true}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"queued_turn","metadata":{"source":"codex-app-server-probe","fileName":"queued_turn.ndjson","description":"One thread with a queued second user turn."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"queued_turn","metadata":{"source":"codex-app-server-probe","fileName":"queued_turn.ndjson","description":"One thread with a queued second user turn."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"simple","metadata":{"source":"codex-app-server-probe","fileName":"simple.ndjson","description":"One thread and one turn with a deterministic text-only response."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"simple","metadata":{"source":"codex-app-server-probe","fileName":"simple.ndjson","description":"One thread and one turn with a deterministic text-only response."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"subagent","metadata":{"source":"codex-app-server-probe","fileName":"subagent.ndjson","description":"One root turn that asks Codex to spawn two collab agents."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"subagent","metadata":{"source":"codex-app-server-probe","fileName":"subagent.ndjson","description":"One root turn that asks Codex to spawn two collab agents."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"subagent_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_continue.ndjson","description":"A root turn spawns one native Codex subagent, then a later root turn resumes and messages the same child thread."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"subagent_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_continue.ndjson","description":"A root turn spawns one native Codex subagent, then a later root turn resumes and messages the same child thread."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.141.0","scenario":"subagent_v2","metadata":{"source":"observed-codex-app-server-replay-fixture","fileName":"subagent_v2.ndjson","description":"Agents-v2 Codex subagent spawn observed from a real run: parent emits subAgentActivity(started), child emits a normal turn and final agent message."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.141.0","scenario":"subagent_v2","metadata":{"source":"observed-codex-app-server-replay-fixture","fileName":"subagent_v2.ndjson","description":"Agents-v2 Codex subagent spawn observed from a real run: parent emits subAgentActivity(started), child emits a normal turn and final agent message."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.141.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.141.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.141.0","scenario":"subagent_v2_nested","metadata":{"source":"observed-codex-app-server-replay-fixture","description":"Agents-v2 Codex nested subagent spawn: root emits a subAgentActivity, that child emits another, and that grandchild emits a final nested child."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.141.0","scenario":"subagent_v2_nested","metadata":{"source":"observed-codex-app-server-replay-fixture","description":"Agents-v2 Codex nested subagent spawn: root emits a subAgentActivity, that child emits another, and that grandchild emits a final nested child."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.141.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.141.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_fork_native","metadata":{"source":"hand-authored-replay","description":"One completed source turn, a native thread/fork, and the first target fork turn."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_fork_native","metadata":{"source":"hand-authored-replay","description":"One completed source turn, a native thread/fork, and the first target fork turn."}} |
| 2 | {"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start/source","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start/source","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_fork_native_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_continue.ndjson","description":"One source marker turn, a native thread fork, a fork-local marker turn, and a recall turn that requires both contexts."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_fork_native_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_continue.ndjson","description":"One source marker turn, a native thread fork, a fork-local marker turn, and a recall turn that requires both contexts."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.124.0-alpha.3","scenario":"thread_fork_native_prior_turn","metadata":{"source":"codex-app-server-live-probe-normalized-for-orchestrator-replay","description":"Two completed source turns, native thread/fork, rollback of the fork to the first source turn, and a fork turn that only sees the first turn.","sourceThreadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkThreadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","firstTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","secondTurnId":"019dd6ba-416d-7902-95cf-d4179c3f4738","repeatTurnId":"019dd6ba-47eb-7041-ad45-5abe752c28c9","normalization":"Native frames are live. Initialize frames are normalized to the T3 adapter client info, and a fork-session initialize was inserted to match orchestrator provider-session lifecycle."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.124.0-alpha.3","scenario":"thread_fork_native_prior_turn","metadata":{"source":"codex-app-server-live-probe-normalized-for-orchestrator-replay","description":"Two completed source turns, native thread/fork, rollback of the fork to the first source turn, and a fork turn that only sees the first turn.","sourceThreadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkThreadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","firstTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","secondTurnId":"019dd6ba-416d-7902-95cf-d4179c3f4738","repeatTurnId":"019dd6ba-47eb-7041-ad45-5abe752c28c9","normalization":"Native frames are live. Initialize frames are normalized to the T3 adapter client info, and a fork-session initialize was inserted to match orchestrator provider-session lifecycle."}} |
| 2 | {"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.124.0-alpha.3","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.124.0-alpha.3","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_fork_native_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_siblings.ndjson","description":"One source marker turn and two independent native forks that each recall the source plus only their own fork marker."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_fork_native_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_siblings.ndjson","description":"One source marker turn and two independent native forks that each recall the source plus only their own fork marker."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_merge_back_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_continue.ndjson","description":"A source thread consumes one fork-delta handoff and later recalls source and transferred context."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_merge_back_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_continue.ndjson","description":"A source thread consumes one fork-delta handoff and later recalls source and transferred context."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_merge_back_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_siblings.ndjson","description":"Two sibling fork deltas are merged sequentially into one source provider thread and recalled together."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_merge_back_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_siblings.ndjson","description":"Two sibling fork deltas are merged sequentially into one source provider thread and recalled together."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_rollback","metadata":{"source":"codex-app-server-probe","fileName":"thread_rollback.ndjson","description":"One thread completes two turns, rolls back the most recent turn, then starts another turn."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_rollback","metadata":{"source":"codex-app-server-probe","fileName":"thread_rollback.ndjson","description":"One thread completes two turns, rolls back the most recent turn, then starts another turn."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"todo_list","metadata":{"source":"codex-app-server-probe","fileName":"todo_list.ndjson","description":"One turn that asks Codex to emit progress through update_plan."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"todo_list","metadata":{"source":"codex-app-server-probe","fileName":"todo_list.ndjson","description":"One turn that asks Codex to emit progress through update_plan."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"tool_call_read_only_on_request","metadata":{"source":"codex-app-server-probe","fileName":"tool_call_read_only_on_request.ndjson","description":"Write a small probe file with read-only full filesystem visibility and on-request approvals."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"tool_call_read_only_on_request","metadata":{"source":"codex-app-server-probe","fileName":"tool_call_read_only_on_request.ndjson","description":"Write a small probe file with read-only full filesystem visibility and on-request approvals."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"tool_call_restricted_granular","metadata":{"source":"codex-app-server-probe","fileName":"tool_call_restricted_granular.ndjson","description":"Write a small probe file with restricted read access and granular approval flags enabled."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"tool_call_restricted_granular","metadata":{"source":"codex-app-server-probe","fileName":"tool_call_restricted_granular.ndjson","description":"Write a small probe file with restricted read access and granular approval flags enabled."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"tool_call_workspace_never","metadata":{"source":"codex-app-server-probe","fileName":"tool_call_workspace_never.ndjson","description":"Write a small probe file with workspace-write sandbox policy and never approvals."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"tool_call_workspace_never","metadata":{"source":"codex-app-server-probe","fileName":"tool_call_workspace_never.ndjson","description":"Write a small probe file with workspace-write sandbox policy and never approvals."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"turn_interrupt","metadata":{"source":"codex-app-server-probe","fileName":"turn_interrupt.ndjson","description":"One active turn is interrupted before it finishes naturally."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"turn_interrupt","metadata":{"source":"codex-app-server-probe","fileName":"turn_interrupt.ndjson","description":"One active turn is interrupted before it finishes naturally."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.128.0","scenario":"turn_interrupt_mid_tool","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"turn_interrupt_mid_tool.ndjson","description":"One active turn is interrupted after Codex has already executed a local command."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.128.0","scenario":"turn_interrupt_mid_tool","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"turn_interrupt_mid_tool.ndjson","description":"One active turn is interrupted after Codex has already executed a local command."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.128.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex_p","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.128.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex_p","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | |||
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"web_search","metadata":{"source":"codex-app-server-probe","fileName":"web_search.ndjson","description":"One turn with a native Codex webSearch item that should become normalized progress."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"web_search","metadata":{"source":"codex-app-server-probe","fileName":"web_search.ndjson","description":"One turn with a native Codex webSearch item that should become normalized progress."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -70,6 +70,7 @@ import Migration0055 from "./Migrations/055_ScheduledTasks.ts"; · 69 unmodified lines | |||
| 70 | import Migration0056 from "./Migrations/056_LegacyV1ImportState.ts"; | 70 | import Migration0056 from "./Migrations/056_LegacyV1ImportState.ts"; |
| 71 | import Migration0057 from "./Migrations/057_ApplicationEventSequenceIndexes.ts"; | 71 | import Migration0057 from "./Migrations/057_ApplicationEventSequenceIndexes.ts"; |
| 72 | import Migration0058 from "./Migrations/058_OrchestrationV2RecoveryIndexes.ts"; | 72 | import Migration0058 from "./Migrations/058_OrchestrationV2RecoveryIndexes.ts"; |
| 73 | import Migration0059 from "./Migrations/059_OrchestrationV2ShellIndexes.ts"; | ||
| 73 | 74 | ||
| 74 | /** | 75 | /** |
| 75 | * Migration loader with all migrations defined inline. | 76 | * Migration loader with all migrations defined inline. |
| @@ -140,6 +141,7 @@ export const migrationEntries = [ · 64 unmodified lines | |||
| 140 | [56, "LegacyV1ImportState", Migration0056], | 141 | [56, "LegacyV1ImportState", Migration0056], |
| 141 | [57, "ApplicationEventSequenceIndexes", Migration0057], | 142 | [57, "ApplicationEventSequenceIndexes", Migration0057], |
| 142 | [58, "OrchestrationV2RecoveryIndexes", Migration0058], | 143 | [58, "OrchestrationV2RecoveryIndexes", Migration0058], |
| 144 | [59, "OrchestrationV2ShellIndexes", Migration0059], | ||
| 143 | ] as const; | 145 | ] as const; |
| 144 | 146 | ||
| 145 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 147 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
| @@ -0,0 +1,24 @@ | |||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | |||
| 7 | yield* sql` | ||
| 8 | CREATE INDEX orchestration_v2_projection_turn_items_thread_run_idx | ||
| 9 | ON orchestration_v2_projection_turn_items(thread_id, run_id) | ||
| 10 | `; | ||
| 11 | // Shell background work includes idle items as well as pending/running/waiting. | ||
| 12 | // Keep this predicate aligned with ProjectionStore's pending-item query. | ||
| 13 | yield* sql` | ||
| 14 | CREATE INDEX orchestration_v2_projection_turn_items_shell_pending_idx | ||
| 15 | ON orchestration_v2_projection_turn_items(thread_id, run_id) | ||
| 16 | WHERE type IN ('command_execution', 'dynamic_tool', 'subagent') | ||
| 17 | AND status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') | ||
| 18 | `; | ||
| 19 | yield* sql` | ||
| 20 | CREATE INDEX orchestration_v2_projection_messages_latest_user_idx | ||
| 21 | ON orchestration_v2_projection_messages(thread_id, updated_at DESC, message_id DESC) | ||
| 22 | WHERE role = 'user' | ||
| 23 | `; | ||
| 24 | }); | ||
| @@ -319,6 +319,22 @@ describe("EventNdjsonLogger", () => { · 318 unmodified lines | |||
| 319 | { method: "thread/realtime/transcript/delta", payload: circularDelta }, | 319 | { method: "thread/realtime/transcript/delta", payload: circularDelta }, |
| 320 | threadId, | 320 | threadId, |
| 321 | ); | 321 | ); |
| 322 | yield* native.write({ method: "turn/diff/updated", payload: circularDelta }, threadId); | ||
| 323 | yield* native.write( | ||
| 324 | { | ||
| 325 | event: { | ||
| 326 | direction: "incoming", | ||
| 327 | stage: "decoded", | ||
| 328 | payload: { | ||
| 329 | method: "turn/diff/updated", | ||
| 330 | get params() { | ||
| 331 | throw new Error("unused diff snapshots must not be traversed"); | ||
| 332 | }, | ||
| 333 | }, | ||
| 334 | }, | ||
| 335 | }, | ||
| 336 | threadId, | ||
| 337 | ); | ||
| 322 | yield* native.write( | 338 | yield* native.write( |
| 323 | { | 339 | { |
| 324 | event: { | 340 | event: { |
| @@ -57,6 +57,7 @@ const transientNativeMethods = new Set([ · 56 unmodified lines | |||
| 57 | "item/reasoning/textDelta", | 57 | "item/reasoning/textDelta", |
| 58 | "thread/realtime/outputAudio/delta", | 58 | "thread/realtime/outputAudio/delta", |
| 59 | "thread/realtime/transcript/delta", | 59 | "thread/realtime/transcript/delta", |
| 60 | "turn/diff/updated", | ||
| 60 | ]); | 61 | ]); |
| 61 | const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); | 62 | const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); |
| 62 | 63 | ||
| @@ -0,0 +1,573 @@ | |||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | ||
| 3 | import { | ||
| 4 | EnvironmentId, | ||
| 5 | type OrchestrationV2ThreadShell, | ||
| 6 | ProjectId, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ThreadId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { RelayAgentActivityState } from "@t3tools/contracts/relay"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | ||
| 12 | import * as Deferred from "effect/Deferred"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Fiber from "effect/Fiber"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Ref from "effect/Ref"; | ||
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | import * as Stream from "effect/Stream"; | ||
| 19 | import * as TestClock from "effect/testing/TestClock"; | ||
| 20 | import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; | ||
| 21 | |||
| 22 | import { SecretStoreReadError, ServerSecretStore } from "../auth/ServerSecretStore.ts"; | ||
| 23 | import { | ||
| 24 | PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 25 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 26 | RELAY_ISSUER_SECRET, | ||
| 27 | RELAY_URL_SECRET, | ||
| 28 | } from "../cloud/config.ts"; | ||
| 29 | import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; | ||
| 30 | import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; | ||
| 31 | import { ProjectService } from "../project/ProjectService.ts"; | ||
| 32 | import { | ||
| 33 | make, | ||
| 34 | makeAgentAwarenessPublishWorker, | ||
| 35 | shouldPublishAgentAwarenessEvent, | ||
| 36 | } from "./AgentAwarenessRelay.ts"; | ||
| 37 | |||
| 38 | const THREAD_ID = ThreadId.make("relay-thread"); | ||
| 39 | const SECOND_THREAD_ID = ThreadId.make("relay-thread-2"); | ||
| 40 | const PROJECT_ID = ProjectId.make("relay-project"); | ||
| 41 | const NOW = "2026-09-04T12:00:00.000Z"; | ||
| 42 | |||
| 43 | function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): OrchestrationV2ThreadShell { | ||
| 44 | return { | ||
| 45 | id: THREAD_ID, | ||
| 46 | projectId: PROJECT_ID, | ||
| 47 | title: "Thread", | ||
| 48 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 49 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" }, | ||
| 50 | runtimeMode: "full-access", | ||
| 51 | interactionMode: "default", | ||
| 52 | worktreePath: null, | ||
| 53 | activeProviderThreadId: null, | ||
| 54 | lineage: { rootThreadId: THREAD_ID, parentThreadId: null, relationshipToParent: null }, | ||
| 55 | forkedFrom: null, | ||
| 56 | createdBy: "user", | ||
| 57 | creationSource: "web", | ||
| 58 | activeRunId: null, | ||
| 59 | latestVisibleMessage: null, | ||
| 60 | hasActionableProposedPlan: false, | ||
| 61 | itemCount: 0, | ||
| 62 | visibleItemCount: 0, | ||
| 63 | lastVisitedAt: null, | ||
| 64 | deletedAt: null, | ||
| 65 | branch: null, | ||
| 66 | linkedPullRequest: null, | ||
| 67 | status: "running", | ||
| 68 | activityRunStatus: null, | ||
| 69 | pendingRuntimeRequest: null, | ||
| 70 | pendingBackgroundTasks: [], | ||
| 71 | latestRunId: null, | ||
| 72 | latestRunRequestedAt: null, | ||
| 73 | latestRunStartedAt: null, | ||
| 74 | latestRunCompletedAt: null, | ||
| 75 | latestUserMessageAt: null, | ||
| 76 | createdAt: DateTime.makeUnsafe(NOW), | ||
| 77 | updatedAt: DateTime.makeUnsafe(NOW), | ||
| 78 | archivedAt: null, | ||
| 79 | settledOverride: null, | ||
| 80 | settledAt: null, | ||
| 81 | snoozedUntil: null, | ||
| 82 | snoozedAt: null, | ||
| 83 | pinnedAt: null, | ||
| 84 | ...overrides, | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | |||
| 88 | const PublishPayload = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState) }); | ||
| 89 | const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(PublishPayload)); | ||
| 90 | const unused = () => Effect.die("Unexpected test dependency call"); | ||
| 91 | |||
| 92 | const makeTestRelay = Effect.fnUntraced(function* ( | ||
| 93 | options: { | ||
| 94 | readonly respond?: (attempt: number) => Response; | ||
| 95 | readonly failSecretRead?: (name: string) => boolean; | ||
| 96 | } = {}, | ||
| 97 | ) { | ||
| 98 | const values = new Map<string, Uint8Array>([ | ||
| 99 | [PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true")], | ||
| 100 | [RELAY_URL_SECRET, new TextEncoder().encode("https://relay.example.test")], | ||
| 101 | [RELAY_ISSUER_SECRET, new TextEncoder().encode("https://relay.example.test")], | ||
| 102 | [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, new TextEncoder().encode("credential-1")], | ||
| 103 | ]); | ||
| 104 | const secretReads: string[] = []; | ||
| 105 | const secrets = ServerSecretStore.of({ | ||
| 106 | get: (name) => | ||
| 107 | Effect.suspend(() => { | ||
| 108 | secretReads.push(name); | ||
| 109 | if (options.failSecretRead?.(name)) { | ||
| 110 | return Effect.fail( | ||
| 111 | new SecretStoreReadError({ resource: name, cause: "temporary read failure" }), | ||
| 112 | ); | ||
| 113 | } | ||
| 114 | return Effect.succeed(Option.fromUndefinedOr(values.get(name))); | ||
| 115 | }), | ||
| 116 | create: (name, value) => Effect.sync(() => void values.set(name, value)), | ||
| 117 | set: (name, value) => Effect.sync(() => void values.set(name, value)), | ||
| 118 | remove: (name) => Effect.sync(() => void values.delete(name)), | ||
| 119 | getOrCreateRandom: unused, | ||
| 120 | }); | ||
| 121 | const currentShell = yield* Ref.make<OrchestrationV2ThreadShell | null>(shell()); | ||
| 122 | const shellReads: ThreadId[] = []; | ||
| 123 | const threads = ThreadManagementService.of({ | ||
| 124 | getThreadShell: (threadId) => | ||
| 125 | Effect.sync(() => shellReads.push(threadId)).pipe(Effect.andThen(Ref.get(currentShell))), | ||
| 126 | getShellSnapshot: unused, | ||
| 127 | ensureLegacyTranscript: unused, | ||
| 128 | dispatch: unused, | ||
| 129 | getThreadProjection: unused, | ||
| 130 | getCheckpointContext: unused, | ||
| 131 | getThreadSnapshot: unused, | ||
| 132 | getThreadSnapshotWindow: unused, | ||
| 133 | getProjectThread: unused, | ||
| 134 | listProjectThreads: unused, | ||
| 135 | sendToThread: unused, | ||
| 136 | waitForThread: unused, | ||
| 137 | interruptThread: unused, | ||
| 138 | getThreadEventSequence: unused, | ||
| 139 | streamStoredEvents: Stream.empty, | ||
| 140 | streamStoredEventsFrom: () => Stream.empty, | ||
| 141 | streamDomainEvents: Stream.empty, | ||
| 142 | }); | ||
| 143 | const publications: Array<{ | ||
| 144 | readonly url: string; | ||
| 145 | readonly authorization: string | null; | ||
| 146 | readonly state: RelayAgentActivityState | null; | ||
| 147 | }> = []; | ||
| 148 | const fetch: typeof globalThis.fetch = Object.assign( | ||
| 149 | ( | ||
| 150 | input: Parameters<typeof globalThis.fetch>[0], | ||
| 151 | init?: Parameters<typeof globalThis.fetch>[1], | ||
| 152 | ) => { | ||
| 153 | const body = init?.body; | ||
| 154 | if (typeof body !== "string" && !(body instanceof Uint8Array)) { | ||
| 155 | return Promise.reject(new Error("Expected a serialized activity publish payload")); | ||
| 156 | } | ||
| 157 | const payload = decodePublishPayload( | ||
| 158 | typeof body === "string" ? body : new TextDecoder().decode(body), | ||
| 159 | ); | ||
| 160 | publications.push({ | ||
| 161 | url: String(input), | ||
| 162 | authorization: new Headers(init?.headers).get("authorization"), | ||
| 163 | state: payload.state, | ||
| 164 | }); | ||
| 165 | return Promise.resolve( | ||
| 166 | options.respond?.(publications.length) ?? Response.json({ ok: true, deliveries: [] }), | ||
| 167 | ); | ||
| 168 | }, | ||
| 169 | { preconnect: () => {} }, | ||
| 170 | ); | ||
| 171 | const relay = yield* make.pipe( | ||
| 172 | Effect.provideService(ServerSecretStore, secrets), | ||
| 173 | Effect.provideService(ThreadManagementService, threads), | ||
| 174 | Effect.provideService(ServerEnvironment, { | ||
| 175 | getEnvironmentId: Effect.succeed(EnvironmentId.make("relay-environment")), | ||
| 176 | getDescriptor: unused(), | ||
| 177 | }), | ||
| 178 | Effect.provideService(ProjectService, { | ||
| 179 | create: unused, | ||
| 180 | bootstrap: unused, | ||
| 181 | update: unused, | ||
| 182 | delete: unused, | ||
| 183 | getByWorkspaceRoot: unused, | ||
| 184 | snapshot: unused(), | ||
| 185 | getById: () => | ||
| 186 | Effect.succeed( | ||
| 187 | Option.some({ | ||
| 188 | id: PROJECT_ID, | ||
| 189 | title: "Project", | ||
| 190 | workspaceRoot: "/workspace", | ||
| 191 | defaultModelSelection: null, | ||
| 192 | scripts: [], | ||
| 193 | createdAt: NOW, | ||
| 194 | updatedAt: NOW, | ||
| 195 | deletedAt: null, | ||
| 196 | }), | ||
| 197 | ), | ||
| 198 | }), | ||
| 199 | Effect.provideService(FetchHttpClient.Fetch, fetch), | ||
| 200 | Effect.provide(NodeCrypto.layer), | ||
| 201 | ); | ||
| 202 | return { relay, secrets, secretReads, currentShell, shellReads, publications }; | ||
| 203 | }); | ||
| 204 | |||
| 205 | describe("AgentAwarenessRelay", () => { | ||
| 206 | it("ignores transcript and tool updates but retains activity and metadata changes", () => { | ||
| 207 | for (const type of [ | ||
| 208 | "message.updated", | ||
| 209 | "turn-item.updated", | ||
| 210 | "provider-turn.updated", | ||
| 211 | "thread.visited", | ||
| 212 | "thread.pinned", | ||
| 213 | ] as const) { | ||
| 214 | assert.isFalse(shouldPublishAgentAwarenessEvent({ type })); | ||
| 215 | } | ||
| 216 | for (const type of [ | ||
| 217 | "run.created", | ||
| 218 | "run.updated", | ||
| 219 | "runtime-request.updated", | ||
| 220 | "thread.metadata-updated", | ||
| 221 | "thread.model-selection-updated", | ||
| 222 | "thread.provider-switched", | ||
| 223 | "thread.archived", | ||
| 224 | "thread.unarchived", | ||
| 225 | "thread.deleted", | ||
| 226 | ] as const) { | ||
| 227 | assert.isTrue(shouldPublishAgentAwarenessEvent({ type })); | ||
| 228 | } | ||
| 229 | }); | ||
| 230 | |||
| 231 | it.effect("coalesces queued updates and reruns a thread dirtied during publishing", () => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const started = yield* Deferred.make<void>(); | ||
| 234 | const release = yield* Deferred.make<void>(); | ||
| 235 | const rerunStarted = yield* Deferred.make<void>(); | ||
| 236 | const releaseRerun = yield* Deferred.make<void>(); | ||
| 237 | const processed: Array<{ threadId: ThreadId; revision: number }> = []; | ||
| 238 | let revision = 1; | ||
| 239 | const worker = yield* makeAgentAwarenessPublishWorker((threadId) => | ||
| 240 | Effect.gen(function* () { | ||
| 241 | const currentRevision = revision; | ||
| 242 | const index = processed.length; | ||
| 243 | processed.push({ threadId, revision: currentRevision }); | ||
| 244 | if (index === 0) { | ||
| 245 | yield* Deferred.succeed(started, undefined); | ||
| 246 | yield* Deferred.await(release); | ||
| 247 | } else if (threadId === THREAD_ID) { | ||
| 248 | yield* Deferred.succeed(rerunStarted, undefined); | ||
| 249 | yield* Deferred.await(releaseRerun); | ||
| 250 | } | ||
| 251 | }), | ||
| 252 | ); | ||
| 253 | yield* worker.enqueue(THREAD_ID); | ||
| 254 | yield* Deferred.await(started); | ||
| 255 | revision = 2; | ||
| 256 | for (let i = 0; i < 100; i++) { | ||
| 257 | yield* worker.enqueue(SECOND_THREAD_ID); | ||
| 258 | yield* worker.enqueue(THREAD_ID); | ||
| 259 | } | ||
| 260 | const drained = yield* Deferred.make<void>(); | ||
| 261 | const draining = yield* worker.drain.pipe( | ||
| 262 | Effect.andThen(Deferred.succeed(drained, undefined)), | ||
| 263 | Effect.forkChild, | ||
| 264 | ); | ||
| 265 | yield* Deferred.succeed(release, undefined); | ||
| 266 | yield* Deferred.await(rerunStarted); | ||
| 267 | assert.isFalse(yield* Deferred.isDone(drained)); | ||
| 268 | yield* Deferred.succeed(releaseRerun, undefined); | ||
| 269 | yield* Fiber.join(draining); | ||
| 270 | assert.deepEqual(processed, [ | ||
| 271 | { threadId: THREAD_ID, revision: 1 }, | ||
| 272 | { threadId: SECOND_THREAD_ID, revision: 2 }, | ||
| 273 | { threadId: THREAD_ID, revision: 2 }, | ||
| 274 | ]); | ||
| 275 | }), | ||
| 276 | ); | ||
| 277 | |||
| 278 | it.effect("deduplicates state and republishes title changes", () => | ||
| 279 | Effect.gen(function* () { | ||
| 280 | const { relay, currentShell, publications } = yield* makeTestRelay(); | ||
| 281 | yield* relay.publishThread(THREAD_ID); | ||
| 282 | yield* Ref.set( | ||
| 283 | currentShell, | ||
| 284 | shell({ updatedAt: DateTime.makeUnsafe("2026-09-04T13:00:00Z") }), | ||
| 285 | ); | ||
| 286 | yield* relay.publishThread(THREAD_ID); | ||
| 287 | assert.equal(publications.length, 1); | ||
| 288 | yield* Ref.set(currentShell, shell({ title: "Renamed thread" })); | ||
| 289 | yield* relay.publishThread(THREAD_ID); | ||
| 290 | assert.equal(publications.length, 2); | ||
| 291 | assert.equal(publications[1]?.state?.threadTitle, "Renamed thread"); | ||
| 292 | }), | ||
| 293 | ); | ||
| 294 | |||
| 295 | it.effect("stops before shell reads when disabled and republishes after re-enabling", () => | ||
| 296 | Effect.gen(function* () { | ||
| 297 | const { relay, secrets, secretReads, shellReads, publications } = yield* makeTestRelay(); | ||
| 298 | yield* relay.publishThread(THREAD_ID); | ||
| 299 | yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); | ||
| 300 | const previousReads = secretReads.length; | ||
| 301 | yield* relay.publishThread(THREAD_ID); | ||
| 302 | assert.deepEqual(secretReads.slice(previousReads), [PUBLISH_AGENT_ACTIVITY_SECRET]); | ||
| 303 | assert.equal(shellReads.length, 1); | ||
| 304 | assert.equal(publications.length, 1); | ||
| 305 | yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true")); | ||
| 306 | yield* relay.publishThread(THREAD_ID); | ||
| 307 | assert.equal(publications.length, 2); | ||
| 308 | }), | ||
| 309 | ); | ||
| 310 | |||
| 311 | it.effect("republishes unchanged state with fresh credentials after relinking", () => | ||
| 312 | Effect.gen(function* () { | ||
| 313 | const { relay, secrets, publications } = yield* makeTestRelay(); | ||
| 314 | yield* relay.publishThread(THREAD_ID); | ||
| 315 | yield* secrets.set( | ||
| 316 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 317 | new TextEncoder().encode("credential-2"), | ||
| 318 | ); | ||
| 319 | yield* relay.publishThread(THREAD_ID); | ||
| 320 | assert.equal(publications.length, 2); | ||
| 321 | assert.equal(publications[0]?.authorization, "Bearer credential-1"); | ||
| 322 | assert.equal(publications[1]?.authorization, "Bearer credential-2"); | ||
| 323 | yield* secrets.set( | ||
| 324 | RELAY_URL_SECRET, | ||
| 325 | new TextEncoder().encode("https://new-relay.example.test"), | ||
| 326 | ); | ||
| 327 | yield* relay.publishThread(THREAD_ID); | ||
| 328 | assert.equal(publications.length, 3); | ||
| 329 | assert.isTrue(publications[2]?.url.startsWith("https://new-relay.example.test/") ?? false); | ||
| 330 | }), | ||
| 331 | ); | ||
| 332 | |||
| 333 | it.effect("retries a failed final state without another thread event", () => | ||
| 334 | Effect.gen(function* () { | ||
| 335 | const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ | ||
| 336 | respond: (attempt) => | ||
| 337 | attempt === 2 | ||
| 338 | ? new Response("relay unavailable", { status: 503 }) | ||
| 339 | : Response.json({ ok: true, deliveries: [] }), | ||
| 340 | }); | ||
| 341 | yield* relay.publishThread(THREAD_ID); | ||
| 342 | yield* Ref.set(currentShell, shell({ status: "completed" })); | ||
| 343 | yield* relay.publishThread(THREAD_ID); | ||
| 344 | assert.equal(publications.length, 2); | ||
| 345 | yield* TestClock.adjust("1 second"); | ||
| 346 | yield* relay.drain; | ||
| 347 | assert.equal(publications.length, 3); | ||
| 348 | assert.equal(publications[2]?.state?.phase, "completed"); | ||
| 349 | yield* TestClock.adjust("1 minute"); | ||
| 350 | yield* relay.drain; | ||
| 351 | assert.equal(shellReads.length, 3); | ||
| 352 | }), | ||
| 353 | ); | ||
| 354 | |||
| 355 | it.effect("retries the latest shell and credentials with first-completion confirmation", () => | ||
| 356 | Effect.gen(function* () { | ||
| 357 | const { relay, secrets, currentShell, publications } = yield* makeTestRelay({ | ||
| 358 | respond: (attempt) => | ||
| 359 | attempt === 1 | ||
| 360 | ? new Response("relay unavailable", { status: 503 }) | ||
| 361 | : Response.json({ ok: true, deliveries: [] }), | ||
| 362 | }); | ||
| 363 | yield* relay.publishThread(THREAD_ID); | ||
| 364 | yield* Ref.set(currentShell, shell({ status: "completed", title: "Final title" })); | ||
| 365 | yield* secrets.set( | ||
| 366 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 367 | new TextEncoder().encode("credential-2"), | ||
| 368 | ); | ||
| 369 | yield* TestClock.adjust("1 second"); | ||
| 370 | yield* relay.drain; | ||
| 371 | assert.equal(publications.length, 1); | ||
| 372 | yield* TestClock.adjust("5 seconds"); | ||
| 373 | yield* relay.drain; | ||
| 374 | assert.equal(publications.length, 2); | ||
| 375 | assert.equal(publications[1]?.authorization, "Bearer credential-2"); | ||
| 376 | assert.equal(publications[1]?.state?.phase, "completed"); | ||
| 377 | assert.equal(publications[1]?.state?.threadTitle, "Final title"); | ||
| 378 | }), | ||
| 379 | ); | ||
| 380 | |||
| 381 | it.effect("cancels a stale retry when a newer update publishes successfully", () => | ||
| 382 | Effect.gen(function* () { | ||
| 383 | const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ | ||
| 384 | respond: (attempt) => | ||
| 385 | attempt === 1 | ||
| 386 | ? new Response("relay unavailable", { status: 503 }) | ||
| 387 | : Response.json({ ok: true, deliveries: [] }), | ||
| 388 | }); | ||
| 389 | yield* relay.publishThread(THREAD_ID); | ||
| 390 | yield* Ref.set(currentShell, shell({ title: "New title" })); | ||
| 391 | yield* relay.publishThread(THREAD_ID); | ||
| 392 | assert.equal(publications.length, 2); | ||
| 393 | assert.equal(publications[1]?.state?.threadTitle, "New title"); | ||
| 394 | yield* TestClock.adjust("1 minute"); | ||
| 395 | yield* relay.drain; | ||
| 396 | assert.equal(shellReads.length, 2); | ||
| 397 | }), | ||
| 398 | ); | ||
| 399 | |||
| 400 | it.effect.each(["disable", "unlink"] as const)( | ||
| 401 | "stops retries before shell reads after %s", | ||
| 402 | (change) => | ||
| 403 | Effect.gen(function* () { | ||
| 404 | const { relay, secrets, shellReads, publications } = yield* makeTestRelay({ | ||
| 405 | respond: () => new Response("relay unavailable", { status: 503 }), | ||
| 406 | }); | ||
| 407 | yield* relay.publishThread(THREAD_ID); | ||
| 408 | if (change === "disable") { | ||
| 409 | yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); | ||
| 410 | } else { | ||
| 411 | yield* secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET); | ||
| 412 | } | ||
| 413 | yield* TestClock.adjust("1 second"); | ||
| 414 | yield* relay.drain; | ||
| 415 | yield* TestClock.adjust("1 minute"); | ||
| 416 | yield* relay.drain; | ||
| 417 | assert.equal(publications.length, 1); | ||
| 418 | assert.equal(shellReads.length, 1); | ||
| 419 | }), | ||
| 420 | ); | ||
| 421 | |||
| 422 | it.effect("recovers from a transient configuration read failure", () => | ||
| 423 | Effect.gen(function* () { | ||
| 424 | let failRead = false; | ||
| 425 | const { relay, shellReads, publications } = yield* makeTestRelay({ | ||
| 426 | failSecretRead: (name) => failRead && name === PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 427 | }); | ||
| 428 | failRead = true; | ||
| 429 | yield* relay.publishThread(THREAD_ID); | ||
| 430 | assert.equal(shellReads.length, 0); | ||
| 431 | failRead = false; | ||
| 432 | yield* TestClock.adjust("1 second"); | ||
| 433 | yield* relay.drain; | ||
| 434 | assert.equal(publications.length, 1); | ||
| 435 | assert.equal(publications[0]?.state?.phase, "running"); | ||
| 436 | }), | ||
| 437 | ); | ||
| 438 | |||
| 439 | it.effect("bounds retry attempts and resets the budget for a newer update", () => | ||
| 440 | Effect.gen(function* () { | ||
| 441 | const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ | ||
| 442 | respond: () => new Response("relay unavailable", { status: 503 }), | ||
| 443 | }); | ||
| 444 | yield* relay.publishThread(THREAD_ID); | ||
| 445 | let attempts = 1; | ||
| 446 | for (const delay of [1_000, 2_000, 4_000, 8_000, 16_000]) { | ||
| 447 | yield* TestClock.adjust(delay - 1); | ||
| 448 | yield* relay.drain; | ||
| 449 | assert.equal(publications.length, attempts); | ||
| 450 | yield* TestClock.adjust(1); | ||
| 451 | yield* relay.drain; | ||
| 452 | attempts += 1; | ||
| 453 | assert.equal(publications.length, attempts); | ||
| 454 | } | ||
| 455 | yield* TestClock.adjust("1 minute"); | ||
| 456 | yield* relay.drain; | ||
| 457 | assert.equal(publications.length, 6); | ||
| 458 | assert.equal(shellReads.length, 6); | ||
| 459 | yield* Ref.set(currentShell, shell({ title: "New update" })); | ||
| 460 | yield* relay.publishThread(THREAD_ID); | ||
| 461 | yield* TestClock.adjust("1 second"); | ||
| 462 | yield* relay.drain; | ||
| 463 | assert.equal(publications.length, 8); | ||
| 464 | assert.equal(publications[7]?.state?.threadTitle, "New update"); | ||
| 465 | }), | ||
| 466 | ); | ||
| 467 | |||
| 468 | it.effect("retries a confirmed completion without restarting its confirmation window", () => | ||
| 469 | Effect.gen(function* () { | ||
| 470 | const { relay, currentShell, publications } = yield* makeTestRelay({ | ||
| 471 | respond: (attempt) => | ||
| 472 | attempt <= 2 | ||
| 473 | ? new Response("relay unavailable", { status: 503 }) | ||
| 474 | : Response.json({ ok: true, deliveries: [] }), | ||
| 475 | }); | ||
| 476 | yield* Ref.set(currentShell, shell({ status: "completed" })); | ||
| 477 | yield* relay.publishThread(THREAD_ID); | ||
| 478 | yield* TestClock.adjust("5 seconds"); | ||
| 479 | yield* relay.drain; | ||
| 480 | assert.equal(publications.length, 1); | ||
| 481 | yield* TestClock.adjust("1 second"); | ||
| 482 | yield* relay.drain; | ||
| 483 | assert.equal(publications.length, 2); | ||
| 484 | yield* TestClock.adjust("2 seconds"); | ||
| 485 | yield* relay.drain; | ||
| 486 | assert.equal(publications.length, 3); | ||
| 487 | assert.isTrue(publications.every((publication) => publication.state?.phase === "completed")); | ||
| 488 | yield* TestClock.adjust("1 minute"); | ||
| 489 | yield* relay.drain; | ||
| 490 | assert.equal(publications.length, 3); | ||
| 491 | }), | ||
| 492 | ); | ||
| 493 | |||
| 494 | it.effect("does not reset an exhausted retry budget for old confirmation timers", () => | ||
| 495 | Effect.gen(function* () { | ||
| 496 | let unavailable = false; | ||
| 497 | const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ | ||
| 498 | respond: () => | ||
| 499 | unavailable | ||
| 500 | ? new Response("relay unavailable", { status: 503 }) | ||
| 501 | : Response.json({ ok: true, deliveries: [] }), | ||
| 502 | }); | ||
| 503 | yield* relay.publishThread(THREAD_ID); | ||
| 504 | // Leave six obsolete confirmations at 5,000 through 5,005 milliseconds. | ||
| 505 | for (let i = 0; i < 6; i++) { | ||
| 506 | yield* Ref.set(currentShell, null); | ||
| 507 | yield* relay.publishThread(THREAD_ID); | ||
| 508 | yield* Ref.set(currentShell, shell()); | ||
| 509 | yield* relay.publishThread(THREAD_ID); | ||
| 510 | yield* TestClock.adjust(1); | ||
| 511 | } | ||
| 512 | unavailable = true; | ||
| 513 | yield* Ref.set(currentShell, shell({ status: "failed" })); | ||
| 514 | yield* relay.publishThread(THREAD_ID); | ||
| 515 | yield* TestClock.adjust(1_000); | ||
| 516 | yield* relay.drain; | ||
| 517 | yield* TestClock.adjust(2_000); | ||
| 518 | yield* relay.drain; | ||
| 519 | yield* TestClock.adjust(1_994); | ||
| 520 | yield* relay.drain; | ||
| 521 | for (let i = 0; i < 5; i++) { | ||
| 522 | yield* TestClock.adjust(1); | ||
| 523 | yield* relay.drain; | ||
| 524 | } | ||
| 525 | const attempts = publications.length; | ||
| 526 | const reads = shellReads.length; | ||
| 527 | yield* TestClock.adjust("1 minute"); | ||
| 528 | yield* relay.drain; | ||
| 529 | assert.equal(publications.length, attempts); | ||
| 530 | assert.equal(shellReads.length, reads); | ||
| 531 | }), | ||
| 532 | ); | ||
| 533 | |||
| 534 | it.effect("confirms tombstones and cancels stale confirmations when activity recovers", () => | ||
| 535 | Effect.gen(function* () { | ||
| 536 | const { relay, currentShell, publications } = yield* makeTestRelay(); | ||
| 537 | yield* relay.publishThread(THREAD_ID); | ||
| 538 | yield* Ref.set(currentShell, null); | ||
| 539 | yield* relay.publishThread(THREAD_ID); | ||
| 540 | assert.equal(publications.length, 1); | ||
| 541 | yield* Ref.set(currentShell, shell()); | ||
| 542 | yield* relay.publishThread(THREAD_ID); | ||
| 543 | yield* TestClock.adjust("5 seconds"); | ||
| 544 | yield* relay.drain; | ||
| 545 | assert.equal(publications.length, 1); | ||
| 546 | yield* Ref.set(currentShell, null); | ||
| 547 | yield* relay.publishThread(THREAD_ID); | ||
| 548 | assert.equal(publications.length, 1); | ||
| 549 | yield* TestClock.adjust("5 seconds"); | ||
| 550 | yield* relay.drain; | ||
| 551 | assert.equal(publications.length, 2); | ||
| 552 | assert.equal(publications[1]?.state, null); | ||
| 553 | }), | ||
| 554 | ); | ||
| 555 | |||
| 556 | it.effect("confirms a first completed state and respects disabling during confirmation", () => | ||
| 557 | Effect.gen(function* () { | ||
| 558 | const { relay, secrets, currentShell, publications } = yield* makeTestRelay(); | ||
| 559 | yield* Ref.set(currentShell, shell({ status: "completed" })); | ||
| 560 | yield* relay.publishThread(THREAD_ID); | ||
| 561 | assert.equal(publications.length, 0); | ||
| 562 | yield* TestClock.adjust("5 seconds"); | ||
| 563 | yield* relay.drain; | ||
| 564 | assert.equal(publications[0]?.state?.phase, "completed"); | ||
| 565 | yield* Ref.set(currentShell, null); | ||
| 566 | yield* relay.publishThread(THREAD_ID); | ||
| 567 | yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); | ||
| 568 | yield* TestClock.adjust("5 seconds"); | ||
| 569 | yield* relay.drain; | ||
| 570 | assert.equal(publications.length, 1); | ||
| 571 | }), | ||
| 572 | ); | ||
| 573 | }); | ||
| @@ -23,6 +23,7 @@ import * as Context from "effect/Context"; · 22 unmodified lines | |||
| 23 | import * as Crypto from "effect/Crypto"; | 23 | import * as Crypto from "effect/Crypto"; |
| 24 | import * as DateTime from "effect/DateTime"; | 24 | import * as DateTime from "effect/DateTime"; |
| 25 | import * as Effect from "effect/Effect"; | 25 | import * as Effect from "effect/Effect"; |
| 26 | import * as Fiber from "effect/Fiber"; | ||
| 26 | import * as Layer from "effect/Layer"; | 27 | import * as Layer from "effect/Layer"; |
| 27 | import * as Option from "effect/Option"; | 28 | import * as Option from "effect/Option"; |
| 28 | import * as Ref from "effect/Ref"; | 29 | import * as Ref from "effect/Ref"; |
| @@ -51,6 +52,7 @@ export class AgentAwarenessRelay extends Context.Service< · 22 unmodified lines | |||
| 51 | AgentAwarenessRelay, | 52 | AgentAwarenessRelay, |
| 52 | { | 53 | { |
| 53 | readonly publishThread: (threadId: ThreadId) => Effect.Effect<void>; | 54 | readonly publishThread: (threadId: ThreadId) => Effect.Effect<void>; |
| 55 | readonly drain: Effect.Effect<void>; | ||
| 54 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; | 56 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; |
| 55 | } | 57 | } |
| 56 | >()("t3/relay/AgentAwarenessRelay") {} | 58 | >()("t3/relay/AgentAwarenessRelay") {} |
| @@ -59,13 +61,74 @@ export function eventThreadId(event: OrchestrationV2DomainEvent): ThreadId { · 2 unmodified lines | |||
| 59 | return event.threadId; | 61 | return event.threadId; |
| 60 | } | 62 | } |
| 61 | 63 | ||
| 62 | export function shouldPublishAgentAwarenessEvent(_event: OrchestrationV2DomainEvent): boolean { | 64 | export function shouldPublishAgentAwarenessEvent( |
| 63 | // Publishing is identity-deduplicated below. Watching every V2 event keeps | 65 | event: Pick<OrchestrationV2DomainEvent, "type">, |
| 64 | // this consumer correct as the shell projection evolves without duplicating | 66 | ): boolean { |
| 65 | // projection-specific relevance rules here. | 67 | // projectThreadAwarenessV2 reads thread metadata, run status, and pending requests. |
| 66 | return true; | 68 | // Message bodies and tool progress cannot change the published activity. |
| 69 | switch (event.type) { | ||
| 70 | case "thread.created": | ||
| 71 | case "thread.archived": | ||
| 72 | case "thread.unarchived": | ||
| 73 | case "thread.deleted": | ||
| 74 | case "thread.metadata-updated": | ||
| 75 | case "thread.model-selection-updated": | ||
| 76 | case "thread.provider-switched": | ||
| 77 | case "run.created": | ||
| 78 | case "run.updated": | ||
| 79 | case "runtime-request.updated": | ||
| 80 | return true; | ||
| 81 | case "thread.settled": | ||
| 82 | case "thread.unsettled": | ||
| 83 | case "thread.snoozed": | ||
| 84 | case "thread.unsnoozed": | ||
| 85 | case "thread.pinned": | ||
| 86 | case "thread.unpinned": | ||
| 87 | case "thread.pin-reordered": | ||
| 88 | case "thread.visited": | ||
| 89 | case "thread.marked-unread": | ||
| 90 | case "thread.runtime-mode-updated": | ||
| 91 | case "thread.interaction-mode-updated": | ||
| 92 | case "run-attempt.created": | ||
| 93 | case "run-attempt.updated": | ||
| 94 | case "node.updated": | ||
| 95 | case "subagent.updated": | ||
| 96 | case "provider-session.attached": | ||
| 97 | case "provider-session.updated": | ||
| 98 | case "provider-session.detached": | ||
| 99 | case "provider-thread.updated": | ||
| 100 | case "provider-turn.updated": | ||
| 101 | case "message.updated": | ||
| 102 | case "turn-item.updated": | ||
| 103 | case "plan.updated": | ||
| 104 | case "checkpoint-scope.created": | ||
| 105 | case "checkpoint.captured": | ||
| 106 | case "checkpoint.rollback-requested": | ||
| 107 | case "context-handoff.updated": | ||
| 108 | case "context-transfer.created": | ||
| 109 | case "context-transfer.updated": | ||
| 110 | return false; | ||
| 111 | } | ||
| 67 | } | 112 | } |
| 68 | 113 | ||
| 114 | export const makeAgentAwarenessPublishWorker = Effect.fnUntraced(function* <R>( | ||
| 115 | publish: (threadId: ThreadId) => Effect.Effect<void, never, R>, | ||
| 116 | ) { | ||
| 117 | const queued = new Set<ThreadId>(); | ||
| 118 | const worker = yield* makeDrainableWorker((threadId: ThreadId) => | ||
| 119 | Effect.sync(() => queued.delete(threadId)).pipe(Effect.andThen(publish(threadId))), | ||
| 120 | ); | ||
| 121 | const enqueue = (threadId: ThreadId) => | ||
| 122 | Effect.suspend(() => { | ||
| 123 | if (queued.has(threadId)) return Effect.void; | ||
| 124 | // Removing the ID when processing starts allows one new queue entry | ||
| 125 | // for updates received while the current snapshot is being published. | ||
| 126 | queued.add(threadId); | ||
| 127 | return worker.enqueue(threadId); | ||
| 128 | }).pipe(Effect.uninterruptible); | ||
| 129 | return { enqueue, drain: worker.drain }; | ||
| 130 | }); | ||
| 131 | |||
| 69 | export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): string { | 132 | export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): string { |
| 70 | if (state === null) { | 133 | if (state === null) { |
| 71 | return "null"; | 134 | return "null"; |
| @@ -90,6 +153,7 @@ export function resolveAgentActivityPublishingStartupState(input: { · 18 unmodified lines | |||
| 90 | 153 | ||
| 91 | const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160; | 154 | const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160; |
| 92 | const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed."; | 155 | const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed."; |
| 156 | const RELAY_AGENT_ACTIVITY_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; | ||
| 93 | 157 | ||
| 94 | export function sanitizeRelayAgentActivityState( | 158 | export function sanitizeRelayAgentActivityState( |
| 95 | state: RelayAgentActivityState | null, | 159 | state: RelayAgentActivityState | null, |
| @@ -268,6 +332,7 @@ export const make = Effect.gen(function* () { · 172 unmodified lines | |||
| 268 | const threads = yield* ThreadManagement.ThreadManagementService; | 332 | const threads = yield* ThreadManagement.ThreadManagementService; |
| 269 | const projects = yield* ProjectService.ProjectService; | 333 | const projects = yield* ProjectService.ProjectService; |
| 270 | const crypto = yield* Crypto.Crypto; | 334 | const crypto = yield* Crypto.Crypto; |
| 335 | const scope = yield* Effect.scope; | ||
| 271 | const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); | 336 | const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); |
| 272 | const activeSnapshotPublishedRef = yield* Ref.make(false); | 337 | const activeSnapshotPublishedRef = yield* Ref.make(false); |
| 273 | const publishedStateByThreadRef = yield* Ref.make(new Map<ThreadId, string>()); | 338 | const publishedStateByThreadRef = yield* Ref.make(new Map<ThreadId, string>()); |
| @@ -305,6 +370,9 @@ export const make = Effect.gen(function* () { · 31 unmodified lines | |||
| 305 | transformClient: relayEnvironmentClient(relayConfig.environmentCredential), | 370 | transformClient: relayEnvironmentClient(relayConfig.environmentCredential), |
| 306 | }).pipe(Effect.provide(FetchHttpClient.layer)); | 371 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 307 | 372 | ||
| 373 | let cachedRelayClient: Effect.Success<ReturnType<typeof makeRelayClient>> | undefined; | ||
| 374 | let publishedRelayConfig: NonNullable<Effect.Success<typeof readRelayConfig>> | undefined; | ||
| 375 | |||
| 308 | // Deadlines for publishes that need confirmation (tombstones and | 376 | // Deadlines for publishes that need confirmation (tombstones and |
| 309 | // first-state completions). The confirming publish is re-enqueued through | 377 | // first-state completions). The confirming publish is re-enqueued through |
| 310 | // the same drainable worker as every other publish, so a confirmed | 378 | // the same drainable worker as every other publish, so a confirmed |
| @@ -312,25 +380,53 @@ export const make = Effect.gen(function* () { · 1 unmodified line | |||
| 312 | // clears the deadline. Assigned after the worker exists. | 380 | // clears the deadline. Assigned after the worker exists. |
| 313 | const publishConfirmDeadlines = new Map<ThreadId, number>(); | 381 | const publishConfirmDeadlines = new Map<ThreadId, number>(); |
| 314 | let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect<void> = () => Effect.void; | 382 | let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect<void> = () => Effect.void; |
| 383 | const publishRetries = new Map< | ||
| 384 | ThreadId, | ||
| 385 | { readonly attempts: number; timer: Fiber.Fiber<void> | undefined } | ||
| 386 | >(); | ||
| 387 | const cancelPublishRetry = Effect.fnUntraced(function* (threadId: ThreadId) { | ||
| 388 | const retry = publishRetries.get(threadId); | ||
| 389 | publishRetries.delete(threadId); | ||
| 390 | if (retry?.timer !== undefined) yield* Fiber.interrupt(retry.timer); | ||
| 391 | }); | ||
| 392 | const cancelPublishRetries = Effect.suspend(() => | ||
| 393 | Effect.forEach([...publishRetries.keys()], cancelPublishRetry, { discard: true }), | ||
| 394 | ); | ||
| 395 | let schedulePublishRetry: (threadId: ThreadId) => Effect.Effect<void> = () => Effect.void; | ||
| 396 | const resetPublishedConnection = Effect.gen(function* () { | ||
| 397 | publishedRelayConfig = undefined; | ||
| 398 | cachedRelayClient = undefined; | ||
| 399 | publishConfirmDeadlines.clear(); | ||
| 400 | yield* Ref.set(publishedStateByThreadRef, new Map()); | ||
| 401 | }); | ||
| 315 | 402 | ||
| 316 | const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { | 403 | const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { |
| 317 | const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( | 404 | const publishAgentActivity = yield* readPublishAgentActivityEnabled; |
| 318 | Effect.orElseSucceed(() => false), | ||
| 319 | ); | ||
| 320 | if (!publishAgentActivity) { | 405 | if (!publishAgentActivity) { |
| 406 | yield* cancelPublishRetries; | ||
| 407 | yield* resetPublishedConnection; | ||
| 321 | yield* Effect.logDebug("agent activity publish skipped; publication disabled", { | 408 | yield* Effect.logDebug("agent activity publish skipped; publication disabled", { |
| 322 | threadId, | 409 | threadId, |
| 323 | }); | 410 | }); |
| 324 | return; | 411 | return; |
| 325 | } | 412 | } |
| 326 | const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); | 413 | const relayConfig = yield* readRelayConfig; |
| 327 | if (!relayConfig) { | 414 | if (!relayConfig) { |
| 415 | yield* cancelPublishRetries; | ||
| 416 | yield* resetPublishedConnection; | ||
| 328 | yield* Effect.logDebug("agent activity publish skipped; relay link credentials unavailable", { | 417 | yield* Effect.logDebug("agent activity publish skipped; relay link credentials unavailable", { |
| 329 | threadId, | 418 | threadId, |
| 330 | }); | 419 | }); |
| 331 | return; | 420 | return; |
| 332 | } | 421 | } |
| 333 | const relayClient = yield* makeRelayClient(relayConfig); | 422 | if ( |
| 423 | publishedRelayConfig?.url !== relayConfig.url || | ||
| 424 | publishedRelayConfig.issuer !== relayConfig.issuer || | ||
| 425 | publishedRelayConfig.environmentCredential !== relayConfig.environmentCredential | ||
| 426 | ) { | ||
| 427 | yield* resetPublishedConnection; | ||
| 428 | publishedRelayConfig = relayConfig; | ||
| 429 | } | ||
| 334 | const environmentId = yield* serverEnvironment.getEnvironmentId; | 430 | const environmentId = yield* serverEnvironment.getEnvironmentId; |
| 335 | 431 | ||
| 336 | const publishState = (input: { | 432 | const publishState = (input: { |
| @@ -339,6 +435,10 @@ export const make = Effect.gen(function* () { · 2 unmodified lines | |||
| 339 | readonly reason: string; | 435 | readonly reason: string; |
| 340 | }) => | 436 | }) => |
| 341 | Effect.gen(function* () { | 437 | Effect.gen(function* () { |
| 438 | if (cachedRelayClient === undefined) { | ||
| 439 | cachedRelayClient = yield* makeRelayClient(relayConfig); | ||
| 440 | } | ||
| 441 | const relayClient = cachedRelayClient; | ||
| 342 | const proof = yield* makePublishProof({ | 442 | const proof = yield* makePublishProof({ |
| 343 | privateKey: cloudLinkKeyPair.privateKey, | 443 | privateKey: cloudLinkKeyPair.privateKey, |
| 344 | relayIssuer: relayConfig.issuer, | 444 | relayIssuer: relayConfig.issuer, |
| @@ -441,7 +541,6 @@ export const make = Effect.gen(function* () { · 96 unmodified lines | |||
| 441 | if (nowMs < deadline) { | 541 | if (nowMs < deadline) { |
| 442 | return; | 542 | return; |
| 443 | } | 543 | } |
| 444 | publishConfirmDeadlines.delete(threadId); | ||
| 445 | yield* Effect.logInfo("agent activity deferred publish confirmed", { | 544 | yield* Effect.logInfo("agent activity deferred publish confirmed", { |
| 446 | environmentId, | 545 | environmentId, |
| 447 | threadId, | 546 | threadId, |
| @@ -471,6 +570,7 @@ export const make = Effect.gen(function* () { · 23 unmodified lines | |||
| 471 | state: snapshot.state, | 570 | state: snapshot.state, |
| 472 | reason: snapshot.reason, | 571 | reason: snapshot.reason, |
| 473 | }); | 572 | }); |
| 573 | publishConfirmDeadlines.delete(threadId); | ||
| 474 | yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { | 574 | yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { |
| 475 | const nextPublishedStates = new Map(publishedStates); | 575 | const nextPublishedStates = new Map(publishedStates); |
| 476 | nextPublishedStates.set(threadId, publishIdentity); | 576 | nextPublishedStates.set(threadId, publishIdentity); |
| @@ -478,17 +578,63 @@ export const make = Effect.gen(function* () { · 1 unmodified line | |||
| 478 | }); | 578 | }); |
| 479 | }); | 579 | }); |
| 480 | 580 | ||
| 481 | const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) => | 581 | const processThreadPublish = Effect.fnUntraced(function* (threadId: ThreadId) { |
| 482 | publishThreadUnsafe(threadId).pipe( | 582 | const retry = publishRetries.get(threadId); |
| 583 | if (retry?.timer !== undefined) { | ||
| 584 | const timer = retry.timer; | ||
| 585 | retry.timer = undefined; | ||
| 586 | yield* Fiber.interrupt(timer); | ||
| 587 | } | ||
| 588 | yield* publishThreadUnsafe(threadId).pipe( | ||
| 589 | Effect.tap(() => cancelPublishRetry(threadId)), | ||
| 483 | Effect.catchCause((cause) => { | 590 | Effect.catchCause((cause) => { |
| 591 | if (Cause.hasInterruptsOnly(cause)) return Effect.void; | ||
| 484 | return Effect.logWarning("agent activity publish failed", { | 592 | return Effect.logWarning("agent activity publish failed", { |
| 485 | threadId, | 593 | threadId, |
| 486 | cause: Cause.pretty(cause), | 594 | cause: Cause.pretty(cause), |
| 487 | }); | 595 | }).pipe(Effect.andThen(schedulePublishRetry(threadId))); |
| 488 | }), | 596 | }), |
| 489 | Effect.withSpan("AgentAwarenessRelay.publishThread"), | 597 | Effect.withSpan("AgentAwarenessRelay.publishThread"), |
| 490 | withRelayClientTracing, | 598 | withRelayClientTracing, |
| 491 | ); | 599 | ); |
| 600 | }); | ||
| 601 | |||
| 602 | const worker = yield* makeAgentAwarenessPublishWorker(processThreadPublish); | ||
| 603 | const enqueueThreadPublish = (threadId: ThreadId) => | ||
| 604 | cancelPublishRetry(threadId).pipe(Effect.andThen(worker.enqueue(threadId))); | ||
| 605 | const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) => | ||
| 606 | enqueueThreadPublish(threadId).pipe(Effect.andThen(worker.drain)); | ||
| 607 | |||
| 608 | schedulePublishRetry = Effect.fnUntraced(function* (threadId: ThreadId) { | ||
| 609 | const attempts = publishRetries.get(threadId)?.attempts ?? 0; | ||
| 610 | const delayMs = RELAY_AGENT_ACTIVITY_RETRY_DELAYS_MS[attempts]; | ||
| 611 | if (delayMs === undefined) { | ||
| 612 | // Keep the exhausted budget until fresh activity or a successful publish | ||
| 613 | // clears it; an old confirmation timer must not start another retry series. | ||
| 614 | yield* Effect.logWarning("agent activity publish retry budget exhausted", { threadId }); | ||
| 615 | return; | ||
| 616 | } | ||
| 617 | const retry = { | ||
| 618 | attempts: attempts + 1, | ||
| 619 | timer: undefined as Fiber.Fiber<void> | undefined, | ||
| 620 | }; | ||
| 621 | publishRetries.set(threadId, retry); | ||
| 622 | const timer = yield* Effect.sleep(delayMs).pipe( | ||
| 623 | Effect.andThen( | ||
| 624 | Effect.suspend(() => { | ||
| 625 | if (publishRetries.get(threadId) !== retry) return Effect.void; | ||
| 626 | retry.timer = undefined; | ||
| 627 | return worker.enqueue(threadId); | ||
| 628 | }), | ||
| 629 | ), | ||
| 630 | Effect.forkIn(scope), | ||
| 631 | ); | ||
| 632 | if (publishRetries.get(threadId) !== retry) { | ||
| 633 | yield* Fiber.interrupt(timer); | ||
| 634 | } else { | ||
| 635 | retry.timer = timer; | ||
| 636 | } | ||
| 637 | }); | ||
| 492 | 638 | ||
| 493 | const publishActiveThreadsUnsafe = Effect.gen(function* () { | 639 | const publishActiveThreadsUnsafe = Effect.gen(function* () { |
| 494 | const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( | 640 | const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( |
| @@ -520,7 +666,8 @@ export const make = Effect.gen(function* () { · 25 unmodified lines | |||
| 520 | yield* Effect.logInfo("publishing active agent activity snapshot", { | 666 | yield* Effect.logInfo("publishing active agent activity snapshot", { |
| 521 | count: activeThreadIds.length, | 667 | count: activeThreadIds.length, |
| 522 | }); | 668 | }); |
| 523 | yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true }); | 669 | yield* Effect.forEach(activeThreadIds, enqueueThreadPublish, { discard: true }); |
| 670 | yield* worker.drain; | ||
| 524 | return true; | 671 | return true; |
| 525 | }); | 672 | }); |
| 526 | 673 | ||
| @@ -542,10 +689,8 @@ export const make = Effect.gen(function* () { · 15 unmodified lines | |||
| 542 | } | 689 | } |
| 543 | }); | 690 | }); |
| 544 | 691 | ||
| 545 | const worker = yield* makeDrainableWorker(publishThread); | ||
| 546 | |||
| 547 | schedulePublishConfirm = (threadId) => | 692 | schedulePublishConfirm = (threadId) => |
| 548 | Effect.forkDetach( | 693 | Effect.forkIn( |
| 549 | Effect.sleep("5 seconds").pipe( | 694 | Effect.sleep("5 seconds").pipe( |
| 550 | Effect.andThen(worker.enqueue(threadId)), | 695 | Effect.andThen(worker.enqueue(threadId)), |
| 551 | Effect.catchCause((cause) => | 696 | Effect.catchCause((cause) => |
| @@ -555,6 +700,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 555 | }), | 700 | }), |
| 556 | ), | 701 | ), |
| 557 | ), | 702 | ), |
| 703 | scope, | ||
| 558 | ).pipe(Effect.asVoid); | 704 | ).pipe(Effect.asVoid); |
| 559 | 705 | ||
| 560 | const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")( | 706 | const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")( |
| @@ -591,18 +737,12 @@ export const make = Effect.gen(function* () { · 30 unmodified lines | |||
| 591 | Stream.runForEach(threads.streamDomainEvents, (event) => { | 737 | Stream.runForEach(threads.streamDomainEvents, (event) => { |
| 592 | const threadId = eventThreadId(event); | 738 | const threadId = eventThreadId(event); |
| 593 | if (!shouldPublishAgentAwarenessEvent(event)) { | 739 | if (!shouldPublishAgentAwarenessEvent(event)) { |
| 594 | return Effect.logDebug( | 740 | return Effect.void; |
| 595 | "agent activity publishing ignored event without activity changes", | ||
| 596 | { | ||
| 597 | eventType: event.type, | ||
| 598 | threadId, | ||
| 599 | }, | ||
| 600 | ); | ||
| 601 | } | 741 | } |
| 602 | return Effect.logDebug("agent activity publishing queued thread publish", { | 742 | return Effect.logDebug("agent activity publishing queued thread publish", { |
| 603 | eventType: event.type, | 743 | eventType: event.type, |
| 604 | threadId, | 744 | threadId, |
| 605 | }).pipe(Effect.andThen(worker.enqueue(threadId))); | 745 | }).pipe(Effect.andThen(enqueueThreadPublish(threadId))); |
| 606 | }), | 746 | }), |
| 607 | ); | 747 | ); |
| 608 | }, | 748 | }, |
| @@ -610,6 +750,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line | |||
| 610 | 750 | ||
| 611 | return AgentAwarenessRelay.of({ | 751 | return AgentAwarenessRelay.of({ |
| 612 | publishThread, | 752 | publishThread, |
| 753 | drain: worker.drain, | ||
| 613 | start, | 754 | start, |
| 614 | }); | 755 | }); |
| 615 | }); | 756 | }); |
| @@ -412,7 +412,7 @@ const OrchestrationApplicationLayerLive = CheckpointDiffQuery.layer.pipe( · 411 unmodified lines | |||
| 412 | // so every client sees the same shelf. | 412 | // so every client sees the same shelf. |
| 413 | const ThreadSettlementWorkerLive = Layer.effectDiscard( | 413 | const ThreadSettlementWorkerLive = Layer.effectDiscard( |
| 414 | ThreadSettlementService.make.pipe(Effect.flatMap((service) => service.start())), | 414 | ThreadSettlementService.make.pipe(Effect.flatMap((service) => service.start())), |
| 415 | ).pipe(Layer.provide(PullRequestServiceLive)); | 415 | ).pipe(Layer.provide(PullRequestServiceLive), Layer.provide(ProjectionStoreV2.layer)); |
| 416 | 416 | ||
| 417 | const AntigravityInstallationRefreshLive = Layer.effectDiscard( | 417 | const AntigravityInstallationRefreshLive = Layer.effectDiscard( |
| 418 | Effect.gen(function* () { | 418 | Effect.gen(function* () { |
| @@ -74,6 +74,11 @@ export interface GitStatusDetails { · 73 unmodified lines | |||
| 74 | aheadOfDefaultCount: number; | 74 | aheadOfDefaultCount: number; |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | export interface GitLocalStatusOptions { | ||
| 78 | /** Skip revision walks and return zero divergence counts for local-only consumers. */ | ||
| 79 | readonly includeDivergence?: boolean; | ||
| 80 | } | ||
| 81 | |||
| 77 | export interface GitRemoteStatusDetails { | 82 | export interface GitRemoteStatusDetails { |
| 78 | isRepo: boolean; | 83 | isRepo: boolean; |
| 79 | defaultBranch: string | null; | 84 | defaultBranch: string | null; |
| @@ -246,7 +251,10 @@ export class GitVcsDriver extends Context.Service< · 166 unmodified lines | |||
| 246 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; | 251 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; |
| 247 | readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>; | 252 | readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>; |
| 248 | readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | 253 | readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; |
| 249 | readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | 254 | readonly statusDetailsLocal: ( |
| 255 | cwd: string, | ||
| 256 | options?: GitLocalStatusOptions, | ||
| 257 | ) => Effect.Effect<GitStatusDetails, GitCommandError>; | ||
| 250 | readonly statusDetailsRemote: ( | 258 | readonly statusDetailsRemote: ( |
| 251 | cwd: string, | 259 | cwd: string, |
| 252 | options?: GitRemoteStatusOptions, | 260 | options?: GitRemoteStatusOptions, |
| @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines | |||
| 8 | import * as FileSystem from "effect/FileSystem"; | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Fiber from "effect/Fiber"; | 9 | import * as Fiber from "effect/Fiber"; |
| 10 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as Logger from "effect/Logger"; | ||
| 11 | import * as Path from "effect/Path"; | 12 | import * as Path from "effect/Path"; |
| 12 | import * as PlatformError from "effect/PlatformError"; | 13 | import * as PlatformError from "effect/PlatformError"; |
| 13 | import * as Ref from "effect/Ref"; | 14 | import * as Ref from "effect/Ref"; |
| @@ -553,7 +554,7 @@ it.effect("refreshes the current branch after an external checkout", () => · 539 unmodified lines | |||
| 553 | ).pipe(Effect.provide(TestLayer)), | 554 | ).pipe(Effect.provide(TestLayer)), |
| 554 | ); | 555 | ); |
| 555 | 556 | ||
| 556 | it.effect("backs off failed upstream refreshes across linked worktrees", () => | 557 | it.effect("backs off and logs failed fetch attempts across linked worktrees", () => |
| 557 | Effect.scoped( | 558 | Effect.scoped( |
| 558 | Effect.gen(function* () { | 559 | Effect.gen(function* () { |
| 559 | const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; | 560 | const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; |
| @@ -573,6 +574,15 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => · 13 unmodified lines | |||
| 573 | const driver = yield* makeGitVcsDriverCore().pipe( | 574 | const driver = yield* makeGitVcsDriverCore().pipe( |
| 574 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), | 575 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), |
| 575 | ); | 576 | ); |
| 577 | const warnings: string[] = []; | ||
| 578 | const logger = Logger.make<unknown, void>(({ message }) => { | ||
| 579 | warnings.push(String(message)); | ||
| 580 | }); | ||
| 581 | const readRemoteStatus = (workingDirectory: string) => | ||
| 582 | driver | ||
| 583 | .statusDetailsRemote(workingDirectory) | ||
| 584 | .pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger]))); | ||
| 585 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 576 | const cwd = yield* makeTmpDir(); | 586 | const cwd = yield* makeTmpDir(); |
| 577 | const remote = yield* makeTmpDir("git-vcs-driver-remote-"); | 587 | const remote = yield* makeTmpDir("git-vcs-driver-remote-"); |
| 578 | const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); | 588 | const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); |
| @@ -616,25 +626,31 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => · 37 unmodified lines | |||
| 616 | ); | 626 | ); |
| 617 | yield* Ref.set(fetchAttempts, 0); | 627 | yield* Ref.set(fetchAttempts, 0); |
| 618 | 628 | ||
| 619 | yield* driver.statusDetailsRemote(cwd); | 629 | yield* readRemoteStatus(cwd); |
| 620 | yield* driver.statusDetailsRemote(worktreePath); | 630 | yield* readRemoteStatus(worktreePath); |
| 621 | assert.equal(yield* Ref.get(fetchAttempts), 1); | 631 | assert.equal(yield* Ref.get(fetchAttempts), 1); |
| 632 | assert.lengthOf(warnings, 1); | ||
| 633 | assert.include(warnings[0], "Background Git fetch failed"); | ||
| 622 | 634 | ||
| 623 | yield* TestClock.adjust("29 seconds"); | 635 | yield* TestClock.adjust("29 seconds"); |
| 624 | yield* driver.statusDetailsRemote(worktreePath); | 636 | yield* readRemoteStatus(worktreePath); |
| 625 | assert.equal(yield* Ref.get(fetchAttempts), 1); | 637 | assert.equal(yield* Ref.get(fetchAttempts), 1); |
| 638 | assert.lengthOf(warnings, 1); | ||
| 626 | 639 | ||
| 627 | yield* TestClock.adjust("1 second"); | 640 | yield* TestClock.adjust("1 second"); |
| 628 | yield* driver.statusDetailsRemote(cwd); | 641 | yield* readRemoteStatus(cwd); |
| 629 | assert.equal(yield* Ref.get(fetchAttempts), 2); | 642 | assert.equal(yield* Ref.get(fetchAttempts), 2); |
| 643 | assert.lengthOf(warnings, 2); | ||
| 630 | 644 | ||
| 631 | yield* TestClock.adjust("59 seconds"); | 645 | yield* TestClock.adjust("59 seconds"); |
| 632 | yield* driver.statusDetailsRemote(worktreePath); | 646 | yield* readRemoteStatus(worktreePath); |
| 633 | assert.equal(yield* Ref.get(fetchAttempts), 2); | 647 | assert.equal(yield* Ref.get(fetchAttempts), 2); |
| 648 | assert.lengthOf(warnings, 2); | ||
| 634 | 649 | ||
| 635 | yield* TestClock.adjust("1 second"); | 650 | yield* TestClock.adjust("1 second"); |
| 636 | yield* driver.statusDetailsRemote(cwd); | 651 | yield* readRemoteStatus(cwd); |
| 637 | assert.equal(yield* Ref.get(fetchAttempts), 3); | 652 | assert.equal(yield* Ref.get(fetchAttempts), 3); |
| 653 | assert.lengthOf(warnings, 3); | ||
| 638 | }), | 654 | }), |
| 639 | ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), | 655 | ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), |
| 640 | ); | 656 | ); |
| @@ -1027,6 +1043,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { · 386 unmodified lines | |||
| 1027 | }), | 1043 | }), |
| 1028 | ); | 1044 | ); |
| 1029 | 1045 | ||
| 1046 | it.effect("can read local files without traversing revision history", () => | ||
| 1047 | Effect.gen(function* () { | ||
| 1048 | const cwd = yield* makeTmpDir(); | ||
| 1049 | const remote = yield* makeTmpDir("git-vcs-driver-remote-"); | ||
| 1050 | const { initialBranch } = yield* initRepoWithCommit(cwd); | ||
| 1051 | yield* git(remote, ["init", "--bare"]); | ||
| 1052 | yield* git(cwd, ["remote", "add", "origin", remote]); | ||
| 1053 | yield* git(cwd, ["push", "-u", "origin", initialBranch]); | ||
| 1054 | yield* git(cwd, ["checkout", "-b", "feature/local-only"]); | ||
| 1055 | yield* writeTextFile(cwd, "feature.txt", "feature\n"); | ||
| 1056 | yield* git(cwd, ["add", "feature.txt"]); | ||
| 1057 | yield* git(cwd, ["commit", "-m", "feature commit"]); | ||
| 1058 | yield* writeTextFile(cwd, "feature.txt", "feature\nlocal edit\n"); | ||
| 1059 | yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); | ||
| 1060 | |||
| 1061 | const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1062 | const commands: ReadonlyArray<string>[] = []; | ||
| 1063 | const spawner = ChildProcessSpawner.make((command) => | ||
| 1064 | Effect.gen(function* () { | ||
| 1065 | if (!ChildProcess.isStandardCommand(command)) { | ||
| 1066 | return yield* Effect.die("expected a standard Git command"); | ||
| 1067 | } | ||
| 1068 | commands.push(command.args); | ||
| 1069 | return yield* delegate.spawn(command); | ||
| 1070 | }), | ||
| 1071 | ); | ||
| 1072 | const driver = yield* makeGitVcsDriverCore().pipe( | ||
| 1073 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 1074 | Effect.provide(ServerConfigLayer), | ||
| 1075 | ); | ||
| 1076 | |||
| 1077 | for (const hasUpstream of [false, true]) { | ||
| 1078 | if (hasUpstream) { | ||
| 1079 | yield* git(cwd, ["push", "-u", "origin", "feature/local-only"]); | ||
| 1080 | } | ||
| 1081 | commands.length = 0; | ||
| 1082 | const local = yield* driver.statusDetailsLocal(cwd, { includeDivergence: false }); | ||
| 1083 | assert.isFalse(commands.some((args) => args.includes("rev-list"))); | ||
| 1084 | assert.isTrue(commands.some((args) => args.includes("--no-ahead-behind"))); | ||
| 1085 | assert.equal(local.hasUpstream, hasUpstream); | ||
| 1086 | assert.isTrue(local.hasWorkingTreeChanges); | ||
| 1087 | assert.deepEqual(local.workingTree.files, [ | ||
| 1088 | { path: "feature.txt", insertions: 1, deletions: 0 }, | ||
| 1089 | { path: "untracked.txt", insertions: 0, deletions: 0 }, | ||
| 1090 | ]); | ||
| 1091 | |||
| 1092 | commands.length = 0; | ||
| 1093 | const full = yield* driver.statusDetailsLocal(cwd); | ||
| 1094 | assert.isTrue(commands.some((args) => args.includes("rev-list"))); | ||
| 1095 | assert.equal(full.aheadCount, hasUpstream ? 0 : 1); | ||
| 1096 | assert.equal(full.aheadOfDefaultCount, 1); | ||
| 1097 | assert.deepEqual(local, { | ||
| 1098 | ...full, | ||
| 1099 | aheadCount: 0, | ||
| 1100 | behindCount: 0, | ||
| 1101 | aheadOfDefaultCount: 0, | ||
| 1102 | }); | ||
| 1103 | } | ||
| 1104 | }), | ||
| 1105 | ); | ||
| 1106 | |||
| 1030 | it.effect("reports remote divergence without reading working-tree details", () => | 1107 | it.effect("reports remote divergence without reading working-tree details", () => |
| 1031 | Effect.gen(function* () { | 1108 | Effect.gen(function* () { |
| 1032 | const cwd = yield* makeTmpDir(); | 1109 | const cwd = yield* makeTmpDir(); |
| @@ -1246,6 +1246,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 1245 unmodified lines | |||
| 1246 | return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe( | 1246 | return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe( |
| 1247 | Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))), | 1247 | Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))), |
| 1248 | Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))), | 1248 | Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))), |
| 1249 | Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)), | ||
| 1249 | Effect.as(true as const), | 1250 | Effect.as(true as const), |
| 1250 | ); | 1251 | ); |
| 1251 | }); | 1252 | }); |
| @@ -1270,13 +1271,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 18 unmodified lines | |||
| 1270 | const upstream = yield* resolveCurrentUpstream(cwd); | 1271 | const upstream = yield* resolveCurrentUpstream(cwd); |
| 1271 | if (!upstream) return; | 1272 | if (!upstream) return; |
| 1272 | const gitCommonDir = yield* resolveGitCommonDir(cwd); | 1273 | const gitCommonDir = yield* resolveGitCommonDir(cwd); |
| 1274 | // The cache loader logs failed attempts; cache hits keep using the last fetched refs. | ||
| 1273 | yield* Cache.get( | 1275 | yield* Cache.get( |
| 1274 | statusRemoteRefreshCache, | 1276 | statusRemoteRefreshCache, |
| 1275 | new StatusRemoteRefreshCacheKey({ | 1277 | new StatusRemoteRefreshCacheKey({ |
| 1276 | gitCommonDir, | 1278 | gitCommonDir, |
| 1277 | remoteName: upstream.remoteName, | 1279 | remoteName: upstream.remoteName, |
| 1278 | }), | 1280 | }), |
| 1279 | ); | 1281 | ).pipe(Effect.ignore); |
| 1280 | }); | 1282 | }); |
| 1281 | 1283 | ||
| 1282 | const resolveDefaultBranchName = ( | 1284 | const resolveDefaultBranchName = ( |
| @@ -1583,11 +1585,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 300 unmodified lines | |||
| 1583 | }; | 1585 | }; |
| 1584 | }); | 1586 | }); |
| 1585 | 1587 | ||
| 1586 | const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { | 1588 | const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* ( |
| 1589 | cwd: string, | ||
| 1590 | options?: GitVcsDriver.GitLocalStatusOptions, | ||
| 1591 | ) { | ||
| 1592 | const includeDivergence = options?.includeDivergence !== false; | ||
| 1593 | const statusArgs = [ | ||
| 1594 | "status", | ||
| 1595 | "--porcelain=2", | ||
| 1596 | "--branch", | ||
| 1597 | ...(includeDivergence ? [] : ["--no-ahead-behind"]), | ||
| 1598 | ]; | ||
| 1587 | const statusResult = yield* executeGitWithStableDiagnostics( | 1599 | const statusResult = yield* executeGitWithStableDiagnostics( |
| 1588 | "GitVcsDriver.statusDetails.status", | 1600 | "GitVcsDriver.statusDetails.status", |
| 1589 | cwd, | 1601 | cwd, |
| 1590 | ["status", "--porcelain=2", "--branch"], | 1602 | statusArgs, |
| 1591 | { | 1603 | { |
| 1592 | allowNonZeroExit: true, | 1604 | allowNonZeroExit: true, |
| 1593 | }, | 1605 | }, |
| @@ -1610,7 +1622,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 16 unmodified lines | |||
| 1610 | ...gitCommandContext({ | 1622 | ...gitCommandContext({ |
| 1611 | operation: "GitVcsDriver.statusDetails.status", | 1623 | operation: "GitVcsDriver.statusDetails.status", |
| 1612 | cwd, | 1624 | cwd, |
| 1613 | args: ["status", "--porcelain=2", "--branch"], | 1625 | args: statusArgs, |
| 1614 | }), | 1626 | }), |
| 1615 | detail: "Git status failed.", | 1627 | detail: "Git status failed.", |
| 1616 | exitCode: statusResult.exitCode, | 1628 | exitCode: statusResult.exitCode, |
| @@ -1710,7 +1722,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 93 unmodified lines | |||
| 1710 | upstreamRef = value.length > 0 ? value : null; | 1722 | upstreamRef = value.length > 0 ? value : null; |
| 1711 | continue; | 1723 | continue; |
| 1712 | } | 1724 | } |
| 1713 | if (line.startsWith("# branch.ab ")) { | 1725 | if (includeDivergence && line.startsWith("# branch.ab ")) { |
| 1714 | const value = line.slice("# branch.ab ".length).trim(); | 1726 | const value = line.slice("# branch.ab ".length).trim(); |
| 1715 | const parsed = parseBranchAb(value); | 1727 | const parsed = parseBranchAb(value); |
| 1716 | aheadCount = parsed.ahead; | 1728 | aheadCount = parsed.ahead; |
| @@ -1725,7 +1737,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 8 unmodified lines | |||
| 1725 | } | 1737 | } |
| 1726 | 1738 | ||
| 1727 | const fallbackAheadCount = | 1739 | const fallbackAheadCount = |
| 1728 | !upstreamRef && refName | 1740 | includeDivergence && !upstreamRef && refName |
| 1729 | ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)) | 1741 | ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)) |
| 1730 | : null; | 1742 | : null; |
| 1731 | 1743 | ||
| @@ -1738,7 +1750,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 6 unmodified lines | |||
| 1738 | refName !== null && | 1750 | refName !== null && |
| 1739 | (refName === defaultBranch || | 1751 | (refName === defaultBranch || |
| 1740 | (defaultBranch === null && (refName === "main" || refName === "master"))); | 1752 | (defaultBranch === null && (refName === "main" || refName === "master"))); |
| 1741 | if (refName && !isDefaultBranch) { | 1753 | if (includeDivergence && refName && !isDefaultBranch) { |
| 1742 | aheadOfDefaultCount = | 1754 | aheadOfDefaultCount = |
| 1743 | fallbackAheadCount !== null | 1755 | fallbackAheadCount !== null |
| 1744 | ? fallbackAheadCount | 1756 | ? fallbackAheadCount |
| @@ -1788,8 +1800,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 43 unmodified lines | |||
| 1788 | 1800 | ||
| 1789 | const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn( | 1801 | const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn( |
| 1790 | "statusDetailsLocal", | 1802 | "statusDetailsLocal", |
| 1791 | )(function* (cwd) { | 1803 | )(function* (cwd, options) { |
| 1792 | return yield* readStatusDetailsLocal(cwd); | 1804 | return yield* readStatusDetailsLocal(cwd, options); |
| 1793 | }); | 1805 | }); |
| 1794 | 1806 | ||
| 1795 | const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn( | 1807 | const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn( |
| @@ -1186,7 +1186,14 @@ function OpenCommandPaletteDialog(props: { · 1185 unmodified lines | |||
| 1186 | projectTitleById, | 1186 | projectTitleById, |
| 1187 | sortOrder: clientSettings.sidebarThreadSortOrder, | 1187 | sortOrder: clientSettings.sidebarThreadSortOrder, |
| 1188 | icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />, | 1188 | icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />, |
| 1189 | renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />, | 1189 | renderLeadingContent: (thread) => ( |
| 1190 | <ThreadRowLeadingStatus | ||
| 1191 | thread={thread} | ||
| 1192 | snapshot={changeRequestSnapshotByKey.get( | ||
| 1193 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 1194 | )} | ||
| 1195 | /> | ||
| 1196 | ), | ||
| 1190 | renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />, | 1197 | renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />, |
| 1191 | renderDescription: (thread, { projectTitle }) => { | 1198 | renderDescription: (thread, { projectTitle }) => { |
| 1192 | const modelInstanceId = | 1199 | const modelInstanceId = |
| @@ -1238,6 +1245,7 @@ function OpenCommandPaletteDialog(props: { · 45 unmodified lines | |||
| 1238 | }), | 1245 | }), |
| 1239 | [ | 1246 | [ |
| 1240 | activeThreadId, | 1247 | activeThreadId, |
| 1248 | changeRequestSnapshotByKey, | ||
| 1241 | clientSettings.sidebarThreadSortOrder, | 1249 | clientSettings.sidebarThreadSortOrder, |
| 1242 | navigate, | 1250 | navigate, |
| 1243 | projectCwdById, | 1251 | projectCwdById, |
| @@ -0,0 +1,180 @@ | |||
| 1 | import { ProjectId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; | ||
| 2 | import { act, type ComponentProps, type ReactNode } from "react"; | ||
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 4 | import { afterEach, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | ||
| 7 | import { makeThreadFixture } from "../test-fixtures"; | ||
| 8 | |||
| 9 | const state = vi.hoisted(() => ({ queries: [] as string[] })); | ||
| 10 | |||
| 11 | vi.mock("../state/entities", () => ({ useProject: () => null })); | ||
| 12 | vi.mock("../uiStateStore", () => ({ useUiStateStore: () => undefined })); | ||
| 13 | vi.mock("../state/vcs", async () => { | ||
| 14 | const { AsyncResult, Atom } = await import("effect/unstable/reactivity"); | ||
| 15 | const statuses = Atom.family((cwd: string) => | ||
| 16 | Atom.make(() => { | ||
| 17 | state.queries.push(`vcs:${cwd}`); | ||
| 18 | return AsyncResult.success({ | ||
| 19 | isRepo: true, | ||
| 20 | hasPrimaryRemote: true, | ||
| 21 | isDefaultRef: false, | ||
| 22 | refName: "feature/test", | ||
| 23 | hasWorkingTreeChanges: false, | ||
| 24 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 25 | hasUpstream: true, | ||
| 26 | aheadCount: 0, | ||
| 27 | behindCount: 0, | ||
| 28 | pr: { | ||
| 29 | number: 42, | ||
| 30 | title: "Observed worktree PR", | ||
| 31 | url: "https://github.com/example/repo/pull/42", | ||
| 32 | baseRef: "main", | ||
| 33 | headRef: "feature/test", | ||
| 34 | state: "open", | ||
| 35 | }, | ||
| 36 | } satisfies VcsStatusResult); | ||
| 37 | }), | ||
| 38 | ); | ||
| 39 | return { | ||
| 40 | vcsEnvironment: { status: ({ input }: { input: { cwd: string } }) => statuses(input.cwd) }, | ||
| 41 | }; | ||
| 42 | }); | ||
| 43 | vi.mock("../state/pullRequests", async (importOriginal) => { | ||
| 44 | const original = await importOriginal<typeof import("../state/pullRequests")>(); | ||
| 45 | const { AsyncResult, Atom } = await import("effect/unstable/reactivity"); | ||
| 46 | const { ProjectId } = await import("@t3tools/contracts"); | ||
| 47 | const summaries = Atom.family((number: number) => | ||
| 48 | Atom.make(() => { | ||
| 49 | state.queries.push(`pr:${number}`); | ||
| 50 | return AsyncResult.success({ | ||
| 51 | provider: "github" as const, | ||
| 52 | projectId: ProjectId.make("project-test"), | ||
| 53 | repository: "example/repo", | ||
| 54 | number, | ||
| 55 | title: "Observed linked PR", | ||
| 56 | url: `https://github.com/example/repo/pull/${number}`, | ||
| 57 | state: "open" as const, | ||
| 58 | headBranch: "feature/test", | ||
| 59 | baseBranch: "main", | ||
| 60 | updatedAt: "2026-09-04T00:00:00.000Z", | ||
| 61 | }); | ||
| 62 | }), | ||
| 63 | ); | ||
| 64 | return { | ||
| 65 | ...original, | ||
| 66 | linkedPullRequestDetailAtom: ({ input }: { input: { number: number } }) => | ||
| 67 | summaries(input.number), | ||
| 68 | }; | ||
| 69 | }); | ||
| 70 | vi.mock("./ui/tooltip", async () => { | ||
| 71 | const { cloneElement, isValidElement } = await import("react"); | ||
| 72 | return { | ||
| 73 | Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>, | ||
| 74 | TooltipTrigger({ | ||
| 75 | render, | ||
| 76 | children, | ||
| 77 | }: ComponentProps<typeof import("./ui/tooltip").TooltipTrigger>) { | ||
| 78 | return isValidElement(render) ? cloneElement(render, undefined, children) : children; | ||
| 79 | }, | ||
| 80 | TooltipPopup: () => null, | ||
| 81 | }; | ||
| 82 | }); | ||
| 83 | |||
| 84 | import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; | ||
| 85 | |||
| 86 | afterEach(() => { | ||
| 87 | vi.unstubAllGlobals(); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("leases only visible palette results while retaining observed and cached PR badges", async () => { | ||
| 91 | const observers: Array<{ setVisible: (visible: boolean) => void }> = []; | ||
| 92 | const scrollRoot = {}; | ||
| 93 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 94 | vi.stubGlobal( | ||
| 95 | "IntersectionObserver", | ||
| 96 | class { | ||
| 97 | constructor(callback: (entries: ReadonlyArray<{ isIntersecting: boolean }>) => void) { | ||
| 98 | observers.push({ setVisible: (visible) => callback([{ isIntersecting: visible }]) }); | ||
| 99 | } | ||
| 100 | observe() {} | ||
| 101 | disconnect() {} | ||
| 102 | }, | ||
| 103 | ); | ||
| 104 | const threads = Array.from({ length: 128 }, (_, index) => | ||
| 105 | makeThreadFixture({ | ||
| 106 | id: ThreadId.make(`palette-thread-${index}`), | ||
| 107 | branch: "feature/test", | ||
| 108 | worktreePath: `/test/worktree-${index}`, | ||
| 109 | linkedPullRequest: | ||
| 110 | index % 2 === 0 | ||
| 111 | ? null | ||
| 112 | : { | ||
| 113 | projectId: ProjectId.make("project-test"), | ||
| 114 | repository: "example/repo", | ||
| 115 | number: index, | ||
| 116 | url: `https://github.com/example/repo/pull/${index}`, | ||
| 117 | }, | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | let renderer: ReactTestRenderer | undefined; | ||
| 121 | state.queries.length = 0; | ||
| 122 | try { | ||
| 123 | await act(async () => { | ||
| 124 | renderer = create( | ||
| 125 | <AppAtomRegistryProvider> | ||
| 126 | {threads.map((thread, index) => ( | ||
| 127 | <span key={thread.id}> | ||
| 128 | <ThreadRowLeadingStatus | ||
| 129 | thread={thread} | ||
| 130 | snapshot={ | ||
| 131 | index === 126 | ||
| 132 | ? { | ||
| 133 | branch: "feature/test", | ||
| 134 | sourceControlProvider: undefined, | ||
| 135 | pr: { | ||
| 136 | number: 126, | ||
| 137 | title: "Cached badge", | ||
| 138 | url: "https://github.com/example/repo/pull/126", | ||
| 139 | baseRef: "main", | ||
| 140 | headRef: "feature/test", | ||
| 141 | state: "open", | ||
| 142 | }, | ||
| 143 | } | ||
| 144 | : undefined | ||
| 145 | } | ||
| 146 | /> | ||
| 147 | {thread.title} | ||
| 148 | </span> | ||
| 149 | ))} | ||
| 150 | </AppAtomRegistryProvider>, | ||
| 151 | { createNodeMock: () => ({ parentElement: { closest: () => scrollRoot } }) }, | ||
| 152 | ); | ||
| 153 | }); | ||
| 154 | const mounted = renderer!; | ||
| 155 | const badgeCount = () => | ||
| 156 | mounted.root.findAll( | ||
| 157 | (node) => node.type === "span" && String(node.props["aria-label"] ?? "").startsWith("PR #"), | ||
| 158 | ).length; | ||
| 159 | expect(observers).toHaveLength(128); | ||
| 160 | expect(state.queries).toEqual([]); | ||
| 161 | expect(badgeCount()).toBe(1); | ||
| 162 | |||
| 163 | await act(async () => { | ||
| 164 | for (const observer of observers.slice(0, 8)) observer.setVisible(true); | ||
| 165 | }); | ||
| 166 | expect(state.queries).toHaveLength(8); | ||
| 167 | expect(state.queries.filter((key) => key.startsWith("vcs:"))).toHaveLength(4); | ||
| 168 | expect(state.queries.filter((key) => key.startsWith("pr:"))).toHaveLength(4); | ||
| 169 | expect(badgeCount()).toBe(9); | ||
| 170 | |||
| 171 | await act(async () => { | ||
| 172 | for (const observer of observers.slice(0, 8)) observer.setVisible(false); | ||
| 173 | for (const observer of observers.slice(8, 16)) observer.setVisible(true); | ||
| 174 | }); | ||
| 175 | expect(state.queries).toHaveLength(16); | ||
| 176 | expect(badgeCount()).toBe(17); | ||
| 177 | } finally { | ||
| 178 | await act(async () => renderer?.unmount()); | ||
| 179 | } | ||
| 180 | }); | ||
| @@ -554,6 +554,25 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { · 553 unmodified lines | |||
| 554 | ).toEqual(mergedPr); | 554 | ).toEqual(mergedPr); |
| 555 | }); | 555 | }); |
| 556 | 556 | ||
| 557 | it("shows the last matching PR snapshot while its live status lease is released", () => { | ||
| 558 | const openPr = { ...mergedPr, state: "open" as const }; | ||
| 559 | const input = { | ||
| 560 | threadBranch: featureBranch, | ||
| 561 | gitStatus: null, | ||
| 562 | snapshot: snapshotFor(featureBranch, openPr, provider), | ||
| 563 | retainTerminalOnBranchMismatch: false, | ||
| 564 | }; | ||
| 565 | expect(resolveDisplayedThreadPr(input)).toEqual(openPr); | ||
| 566 | expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider); | ||
| 567 | expect(resolveDisplayedThreadPr({ ...input, threadBranch: "feature/other" })).toBeNull(); | ||
| 568 | expect( | ||
| 569 | resolveDisplayedThreadPr({ | ||
| 570 | ...input, | ||
| 571 | snapshot: { ...input.snapshot, linkedPullRequest }, | ||
| 572 | }), | ||
| 573 | ).toBeNull(); | ||
| 574 | }); | ||
| 575 | |||
| 557 | it("retains a merged PR after a main checkout", () => { | 576 | it("retains a merged PR after a main checkout", () => { |
| 558 | const matchingStatus = status({ | 577 | const matchingStatus = status({ |
| 559 | refName: featureBranch, | 578 | refName: featureBranch, |
| @@ -12,7 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { Atom } from "effect/unstable/reactivity"; | 13 | import { Atom } from "effect/unstable/reactivity"; |
| 14 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; | 14 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; |
| 15 | import { useMemo } from "react"; | 15 | import { useCallback, useMemo } from "react"; |
| 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 17 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; | 17 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 18 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 18 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| @@ -27,6 +27,8 @@ import { · 8 unmodified lines | |||
| 27 | resolveThreadLastVisitedAt, | 27 | resolveThreadLastVisitedAt, |
| 28 | resolveThreadStatusPill, | 28 | resolveThreadStatusPill, |
| 29 | type ThreadStatusPill, | 29 | type ThreadStatusPill, |
| 30 | useRetainedValue, | ||
| 31 | useSidebarRowSubscriptionLease, | ||
| 30 | } from "./Sidebar.logic"; | 32 | } from "./Sidebar.logic"; |
| 31 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; | 33 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; |
| 32 | import type { SidebarThreadSummary } from "../types"; | 34 | import type { SidebarThreadSummary } from "../types"; |
| @@ -58,9 +60,10 @@ export interface LinkedThreadPullRequestStatus { · 25 unmodified lines | |||
| 58 | export function useLinkedThreadPullRequest( | 60 | export function useLinkedThreadPullRequest( |
| 59 | environmentId: EnvironmentId | null, | 61 | environmentId: EnvironmentId | null, |
| 60 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, | 62 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, |
| 63 | enabled = true, | ||
| 61 | ): LinkedThreadPullRequestStatus | null { | 64 | ): LinkedThreadPullRequestStatus | null { |
| 62 | const queried = useEnvironmentQuery( | 65 | const queried = useEnvironmentQuery( |
| 63 | environmentId === null || linkedPullRequest == null | 66 | !enabled || environmentId === null || linkedPullRequest == null |
| 64 | ? null | 67 | ? null |
| 65 | : linkedPullRequestDetailAtom({ | 68 | : linkedPullRequestDetailAtom({ |
| 66 | environmentId, | 69 | environmentId, |
| @@ -353,7 +356,7 @@ export function nextThreadChangeRequestSnapshot(input: { · 286 unmodified lines | |||
| 353 | * checkouts only, a cached merged/closed PR for the thread. Local thread | 356 | * checkouts only, a cached merged/closed PR for the thread. Local thread |
| 354 | * metadata follows the shared checkout, so the cached branch intentionally | 357 | * metadata follows the shared checkout, so the cached branch intentionally |
| 355 | * survives that metadata changing to the newly checked-out branch. Open PRs | 358 | * survives that metadata changing to the newly checked-out branch. Open PRs |
| 356 | * are never retained — their state can still change. | 359 | * are retained only while live status is absent and the branch still matches. |
| 357 | */ | 360 | */ |
| 358 | export function resolveDisplayedThreadPr(input: { | 361 | export function resolveDisplayedThreadPr(input: { |
| 359 | threadBranch: string | null; | 362 | threadBranch: string | null; |
| @@ -388,6 +391,15 @@ export function resolveDisplayedThreadPr(input: { · 28 unmodified lines | |||
| 388 | return gitStatus.pr; | 391 | return gitStatus.pr; |
| 389 | } | 392 | } |
| 390 | 393 | ||
| 394 | if ( | ||
| 395 | gitStatus === null && | ||
| 396 | threadBranch !== null && | ||
| 397 | snapshot?.branch === threadBranch && | ||
| 398 | snapshot.linkedPullRequest === undefined | ||
| 399 | ) { | ||
| 400 | return snapshot.pr; | ||
| 401 | } | ||
| 402 | |||
| 391 | if ( | 403 | if ( |
| 392 | threadBranch !== null && | 404 | threadBranch !== null && |
| 393 | retainTerminalOnBranchMismatch && | 405 | retainTerminalOnBranchMismatch && |
| @@ -434,6 +446,15 @@ export function resolveDisplayedThreadPrProvider(input: { · 40 unmodified lines | |||
| 434 | return gitStatus.sourceControlProvider; | 446 | return gitStatus.sourceControlProvider; |
| 435 | } | 447 | } |
| 436 | 448 | ||
| 449 | if ( | ||
| 450 | gitStatus === null && | ||
| 451 | threadBranch !== null && | ||
| 452 | snapshot?.branch === threadBranch && | ||
| 453 | snapshot.linkedPullRequest === undefined | ||
| 454 | ) { | ||
| 455 | return snapshot.sourceControlProvider; | ||
| 456 | } | ||
| 457 | |||
| 437 | if ( | 458 | if ( |
| 438 | threadBranch !== null && | 459 | threadBranch !== null && |
| 439 | retainTerminalOnBranchMismatch && | 460 | retainTerminalOnBranchMismatch && |
| @@ -550,7 +571,19 @@ export function ThreadStatusLabel({ · 110 unmodified lines | |||
| 550 | * like the command palette. Shows the change request state icon (if present) and the | 571 | * like the command palette. Shows the change request state icon (if present) and the |
| 551 | * thread status dot, matching the sidebar's leading indicators. | 572 | * thread status dot, matching the sidebar's leading indicators. |
| 552 | */ | 573 | */ |
| 553 | export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { | 574 | export function ThreadRowLeadingStatus({ |
| 575 | thread, | ||
| 576 | snapshot, | ||
| 577 | }: { | ||
| 578 | thread: SidebarThreadSummary; | ||
| 579 | snapshot?: ThreadChangeRequestSnapshot | undefined; | ||
| 580 | }) { | ||
| 581 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(false); | ||
| 582 | // Observe the containing title even when this thread has no badge yet. | ||
| 583 | const statusRef = useCallback( | ||
| 584 | (node: HTMLSpanElement | null) => rowRef(node?.parentElement ?? null), | ||
| 585 | [rowRef], | ||
| 586 | ); | ||
| 554 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); | 587 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| 555 | const localLastVisitedAt = useUiStateStore( | 588 | const localLastVisitedAt = useUiStateStore( |
| 556 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], | 589 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], |
| @@ -567,9 +600,11 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 10 unmodified lines | |||
| 567 | const linkedPullRequest = useLinkedThreadPullRequest( | 600 | const linkedPullRequest = useLinkedThreadPullRequest( |
| 568 | thread.environmentId, | 601 | thread.environmentId, |
| 569 | thread.linkedPullRequest, | 602 | thread.linkedPullRequest, |
| 603 | leaseLiveStatus, | ||
| 570 | ); | 604 | ); |
| 571 | const gitStatus = useEnvironmentQuery( | 605 | const gitStatus = useEnvironmentQuery( |
| 572 | thread.linkedPullRequest == null && | 606 | leaseLiveStatus && |
| 607 | thread.linkedPullRequest == null && | ||
| 573 | (thread.branch != null || thread.worktreePath !== null) && | 608 | (thread.branch != null || thread.worktreePath !== null) && |
| 574 | gitCwd !== null | 609 | gitCwd !== null |
| 575 | ? vcsEnvironment.status({ | 610 | ? vcsEnvironment.status({ |
| @@ -578,14 +613,20 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 2 unmodified lines | |||
| 578 | }) | 613 | }) |
| 579 | : null, | 614 | : null, |
| 580 | ); | 615 | ); |
| 581 | const pr = | 616 | const visibleGitStatus = useRetainedValue( |
| 582 | thread.linkedPullRequest == null | 617 | JSON.stringify([thread.environmentId, gitCwd]), |
| 583 | ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) | 618 | gitStatus.data, |
| 584 | : (linkedPullRequest?.pr ?? null); | ||
| 585 | const prStatus = prStatusIndicator( | ||
| 586 | pr, | ||
| 587 | linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, | ||
| 588 | ); | 619 | ); |
| 620 | const displayedPrInput = { | ||
| 621 | threadBranch: thread.branch, | ||
| 622 | gitStatus: visibleGitStatus, | ||
| 623 | snapshot, | ||
| 624 | retainTerminalOnBranchMismatch: thread.worktreePath === null, | ||
| 625 | linkedPullRequest: thread.linkedPullRequest, | ||
| 626 | linkedPullRequestStatus: linkedPullRequest, | ||
| 627 | }; | ||
| 628 | const pr = resolveDisplayedThreadPr(displayedPrInput); | ||
| 629 | const prStatus = prStatusIndicator(pr, resolveDisplayedThreadPrProvider(displayedPrInput)); | ||
| 589 | const threadStatus = resolveThreadStatusPill({ | 630 | const threadStatus = resolveThreadStatusPill({ |
| 590 | thread: { | 631 | thread: { |
| 591 | ...thread, | 632 | ...thread, |
| @@ -593,12 +634,13 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 1 unmodified line | |||
| 593 | }, | 634 | }, |
| 594 | }); | 635 | }); |
| 595 | 636 | ||
| 596 | if (!prStatus && !threadStatus) { | ||
| 597 | return null; | ||
| 598 | } | ||
| 599 | |||
| 600 | return ( | 637 | return ( |
| 601 | <span className="inline-flex shrink-0 items-center gap-1.5"> | 638 | <span |
| 639 | ref={statusRef} | ||
| 640 | className={ | ||
| 641 | prStatus || threadStatus ? "inline-flex shrink-0 items-center gap-1.5" : "contents" | ||
| 642 | } | ||
| 643 | > | ||
| 602 | {prStatus && pr ? ( | 644 | {prStatus && pr ? ( |
| 603 | <Tooltip> | 645 | <Tooltip> |
| 604 | <TooltipTrigger | 646 | <TooltipTrigger |
| @@ -2,7 +2,11 @@ import { · 1 unmodified line | |||
| 2 | BearerConnectionTarget, | 2 | BearerConnectionTarget, |
| 3 | PrimaryConnectionTarget, | 3 | PrimaryConnectionTarget, |
| 4 | } from "@t3tools/client-runtime/connection"; | 4 | } from "@t3tools/client-runtime/connection"; |
| 5 | import { EnvironmentId, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | 5 | import { |
| 6 | type DesktopEnvironmentBootstrap, | ||
| 7 | EnvironmentId, | ||
| 8 | PRIMARY_LOCAL_ENVIRONMENT_ID, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "vite-plus/test"; | 10 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 11 | ||
| 8 | import { | 12 | import { |
| @@ -38,6 +42,63 @@ describe("desktop local connection identity", () => { · 29 unmodified lines | |||
| 38 | }); | 42 | }); |
| 39 | 43 | ||
| 40 | describe("desktop local topology reads", () => { | 44 | describe("desktop local topology reads", () => { |
| 45 | it("reuses snapshots when polling returns fresh objects for the same topology", () => { | ||
| 46 | const secondary: DesktopEnvironmentBootstrap = { | ||
| 47 | id: "wsl:default", | ||
| 48 | label: "WSL", | ||
| 49 | runningDistro: "Ubuntu", | ||
| 50 | httpBaseUrl: "http://127.0.0.1:4000", | ||
| 51 | wsBaseUrl: "ws://127.0.0.1:4000", | ||
| 52 | bootstrapToken: "bootstrap-1", | ||
| 53 | }; | ||
| 54 | let entries = [secondary]; | ||
| 55 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ | ||
| 56 | getLocalEnvironmentBootstraps: () => entries.map((entry) => ({ ...entry })), | ||
| 57 | })); | ||
| 58 | |||
| 59 | const connected = reader.readSnapshot(); | ||
| 60 | expect(reader.readSnapshot()).toBe(connected); | ||
| 61 | expect(reader.readResult()).toEqual({ _tag: "Success", bootstraps: connected }); | ||
| 62 | expect(reader.readSnapshot()).toBe(connected); | ||
| 63 | |||
| 64 | entries = []; | ||
| 65 | const empty = reader.readSnapshot(); | ||
| 66 | expect(empty).toEqual([]); | ||
| 67 | expect(reader.readSnapshot()).toBe(empty); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it.each<Partial<DesktopEnvironmentBootstrap>>([ | ||
| 71 | { id: "wsl:Debian" }, | ||
| 72 | { label: "Renamed backend" }, | ||
| 73 | { runningDistro: "Debian" }, | ||
| 74 | { httpBaseUrl: "http://127.0.0.1:4001" }, | ||
| 75 | { wsBaseUrl: "ws://127.0.0.1:4001" }, | ||
| 76 | { bootstrapToken: "bootstrap-2" }, | ||
| 77 | ])("publishes bootstrap changes: %j", (change) => { | ||
| 78 | let secondary: DesktopEnvironmentBootstrap = { | ||
| 79 | id: "wsl:default", | ||
| 80 | label: "WSL", | ||
| 81 | runningDistro: "Ubuntu", | ||
| 82 | httpBaseUrl: "http://127.0.0.1:4000", | ||
| 83 | wsBaseUrl: "ws://127.0.0.1:4000", | ||
| 84 | bootstrapToken: "bootstrap-1", | ||
| 85 | }; | ||
| 86 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ | ||
| 87 | getLocalEnvironmentBootstraps: () => [{ ...secondary }], | ||
| 88 | })); | ||
| 89 | const before = reader.readSnapshot(); | ||
| 90 | secondary = { ...secondary, ...change }; | ||
| 91 | const after = reader.readSnapshot(); | ||
| 92 | expect(after).not.toBe(before); | ||
| 93 | expect(after).toEqual([secondary]); | ||
| 94 | expect(reader.readSnapshot()).toBe(after); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("keeps the empty snapshot stable without a desktop bridge", () => { | ||
| 98 | const reader = createDesktopSecondaryBootstrapsReader(() => undefined); | ||
| 99 | expect(reader.readSnapshot()).toBe(reader.readSnapshot()); | ||
| 100 | }); | ||
| 101 | |||
| 41 | it("distinguishes a successful empty topology from a read failure", () => { | 102 | it("distinguishes a successful empty topology from a read failure", () => { |
| 42 | let readBootstraps = () => []; | 103 | let readBootstraps = () => []; |
| 43 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ | 104 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ |
| @@ -67,13 +67,30 @@ export function createDesktopSecondaryBootstrapsReader( · 66 unmodified lines | |||
| 67 | const readResult = (): DesktopSecondaryBootstrapsRead => { | 67 | const readResult = (): DesktopSecondaryBootstrapsRead => { |
| 68 | const bridge = resolveBridge(); | 68 | const bridge = resolveBridge(); |
| 69 | if (bridge === undefined) { | 69 | if (bridge === undefined) { |
| 70 | snapshot = []; | 70 | if (snapshot.length > 0) snapshot = []; |
| 71 | return { _tag: "Success", bootstraps: snapshot }; | 71 | return { _tag: "Success", bootstraps: snapshot }; |
| 72 | } | 72 | } |
| 73 | try { | 73 | try { |
| 74 | snapshot = bridge | 74 | const next = bridge |
| 75 | .getLocalEnvironmentBootstraps() | 75 | .getLocalEnvironmentBootstraps() |
| 76 | .filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID); | 76 | .filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID); |
| 77 | if ( | ||
| 78 | next.length !== snapshot.length || | ||
| 79 | next.some((entry, index) => { | ||
| 80 | const previous = snapshot[index]; | ||
| 81 | return ( | ||
| 82 | previous === undefined || | ||
| 83 | entry.id !== previous.id || | ||
| 84 | entry.label !== previous.label || | ||
| 85 | entry.runningDistro !== previous.runningDistro || | ||
| 86 | entry.httpBaseUrl !== previous.httpBaseUrl || | ||
| 87 | entry.wsBaseUrl !== previous.wsBaseUrl || | ||
| 88 | entry.bootstrapToken !== previous.bootstrapToken | ||
| 89 | ); | ||
| 90 | }) | ||
| 91 | ) { | ||
| 92 | snapshot = next; | ||
| 93 | } | ||
| 77 | return { _tag: "Success", bootstraps: snapshot }; | 94 | return { _tag: "Success", bootstraps: snapshot }; |
| 78 | } catch (cause) { | 95 | } catch (cause) { |
| 79 | return { _tag: "Failure", cause }; | 96 | return { _tag: "Failure", cause }; |